diff --git a/Cargo.lock b/Cargo.lock index 1f6ea38..117e5f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,7 +75,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -113,6 +113,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -125,6 +131,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" @@ -147,6 +159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -163,9 +176,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" dependencies = [ "find-msvc-tools", "jobserver", @@ -241,7 +254,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -259,6 +272,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -352,6 +371,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -372,9 +397,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64" dependencies = [ "cfg-if", ] @@ -406,6 +431,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "num-traits", + "rand_core 0.10.1", + "serdect", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -422,7 +462,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] @@ -466,6 +538,8 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", + "zeroize", ] [[package]] @@ -497,7 +571,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -528,9 +602,11 @@ dependencies = [ "async-trait", "dirs", "elemental-schema", + "erased-serde", "futures", "md-5", "minecraft-msa-auth", + "nidhogg", "oauth2", "oauth2-reqwest", "reqwest", @@ -600,6 +676,13 @@ dependencies = [ "elemental-core", "elemental-driver", "elemental-infra", + "elemental-shared", + "erased-serde", + "hex", + "nidhogg", + "serde", + "serde_json", + "sha2 0.10.9", "thiserror 2.0.21", "tokio", ] @@ -661,6 +744,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -668,7 +762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -679,9 +773,9 @@ checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" dependencies = [ "zlib-rs", ] @@ -763,7 +857,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -889,6 +983,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -1177,9 +1280,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" [[package]] name = "jiff" @@ -1425,9 +1528,19 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" -version = "0.3.17" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] [[package]] name = "minecraft-msa-auth" @@ -1464,6 +1577,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nidhogg" +version = "0.3.0" +source = "git+https://github.com/ElectronicWave/nidhogg?rev=68eaf3c7445ce34bfa6f3f5874f6fe28d1f7f2b8#68eaf3c7445ce34bfa6f3f5874f6fe28d1f7f2b8" +dependencies = [ + "base64 0.23.1", + "md-5", + "reqwest", + "rsa", + "serde", + "serde_json", + "sha1", + "thiserror 2.0.21", + "url", +] + [[package]] name = "nix" version = "0.31.3" @@ -1532,7 +1661,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "url", ] @@ -1594,6 +1723,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1606,6 +1744,26 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6250dd7321adbe93d12b750aee0ced159fd13e77f625edcdf1cb6f83cd99f650" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1718,7 +1876,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1832,6 +1990,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -1868,6 +2027,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e77fd77e0dea28b3011fd62d15445b7de7959333b0276d930e03be0f755699" +dependencies = [ + "const-oid", + "crypto-bigint", + "crypto-common 0.2.2", + "crypto-primes", + "digest 0.11.3", + "hmac", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.1", + "signature", + "spki", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1893,7 +2073,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1950,7 +2130,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1979,9 +2159,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "saa" -version = "5.6.2" +version = "5.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd85992a680de788217d9c70b74aa7685b108cf1aba02f728fb27f1b27199ad1" +checksum = "4e80a530676d97ef2167d7fb184a860166cd265bf98be494064828d7aaa43446" [[package]] name = "same-file" @@ -2083,7 +2263,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -2119,6 +2299,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2130,6 +2320,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2141,6 +2337,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2187,6 +2394,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2217,9 +2434,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.4" +version = "1.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" [[package]] name = "socket2" @@ -2231,6 +2448,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2262,9 +2489,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" dependencies = [ "proc-macro2", "quote", @@ -2288,7 +2515,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -2349,7 +2576,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -2401,7 +2628,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] @@ -2582,12 +2809,24 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -2664,7 +2903,10 @@ version = "1.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" dependencies = [ + "getrandom 0.4.3", "js-sys", + "serde_core", + "sha1_smol", "wasm-bindgen", ] @@ -2747,7 +2989,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", "wasm-bindgen-shared", ] @@ -2824,7 +3066,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2987,9 +3229,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" [[package]] name = "winreg" @@ -3026,7 +3268,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", "synstructure", ] @@ -3067,7 +3309,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", "synstructure", ] @@ -3121,7 +3363,7 @@ checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.10", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c2fa6bc..226ba9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", diff --git a/README.md b/README.md index 54b27e1..6d85022 100644 --- a/README.md +++ b/README.md @@ -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}, }; @@ -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( @@ -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: @@ -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 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b21fa09..234021a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -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 } @@ -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" diff --git a/crates/core/README.md b/crates/core/README.md index 5423783..099b558 100644 --- a/crates/core/README.md +++ b/crates/core/README.md @@ -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. @@ -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); @@ -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 | @@ -79,6 +85,13 @@ implements `Authorizer` for real accounts. | `storage` | Generic `Storage` and `Layout` resource resolution | | `version` | The tagged `Version` 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 @@ -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 diff --git a/crates/core/src/auth/accounts/microsoft.rs b/crates/core/src/auth/accounts/microsoft.rs new file mode 100644 index 0000000..3df0b4c --- /dev/null +++ b/crates/core/src/auth/accounts/microsoft.rs @@ -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, + 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 { + 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> { + Ok(Box::new( + crate::auth::authorizers::microsoft::refresh(self).await?, + )) + } + + async fn textures(&self) -> Result { + profile_textures( + crate::auth::authorizers::microsoft::minecraft_profile(&self.access_token).await?, + ) + } + + async fn upload_skin(&self, image: Vec, 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) -> 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 { + 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, +) -> Result> { + 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 { + 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"), + } +} diff --git a/crates/core/src/auth/accounts/mod.rs b/crates/core/src/auth/accounts/mod.rs new file mode 100644 index 0000000..56b0b8a --- /dev/null +++ b/crates/core/src/auth/accounts/mod.rs @@ -0,0 +1,97 @@ +pub mod microsoft; +pub mod offline; +#[cfg(feature = "yggdrasil-auth")] +pub mod yggdrasil; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::credential::Credential; +use crate::auth::error::Result; +use crate::auth::texture::{ProfileTextures, SkinModel}; + +fn unsupported(operation: &'static str) -> Result { + Err(crate::auth::error::Error::TextureUnsupported { operation }) +} + +/// Stable identity of a persisted account. +/// +/// This is independent from the Minecraft profile UUID because Yggdrasil +/// profiles from different API roots may share the same profile ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AccountId(Uuid); + +impl AccountId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + /// Derives a stable account identity from its authorizer and provider-specific identity. + pub fn derive(authorizer_id: &str, identity: &str) -> Self { + Self(Uuid::new_v5( + &Uuid::NAMESPACE_URL, + format!("elemental:{authorizer_id}:{identity}").as_bytes(), + )) + } +} + +impl Default for AccountId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for AccountId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +/// Persisted authentication state owned by one [`crate::auth::authorizers::Authorizer`]. +#[async_trait] +pub trait Account: Send + Sync + erased_serde::Serialize { + fn account_id(&self) -> AccountId; + + fn authorizer_id(&self) -> &'static str; + + fn display_name(&self) -> &str; + + fn needs_refresh(&self) -> bool { + false + } + + async fn credential(&self) -> Result; + + async fn refresh(&self) -> Result>; + + async fn textures(&self) -> Result { + unsupported("read textures") + } + + async fn upload_skin(&self, _image: Vec, _model: SkinModel) -> Result<()> { + unsupported("upload skin") + } + + async fn delete_skin(&self) -> Result<()> { + unsupported("delete skin") + } + + async fn upload_cape(&self, _image: Vec) -> Result<()> { + unsupported("upload cape") + } + + async fn delete_cape(&self) -> Result<()> { + unsupported("delete cape") + } + + /// Selects an owned cape, or clears the active cape when `cape_id` is `None`. + async fn select_cape(&self, _cape_id: Option) -> Result<()> { + unsupported("select cape") + } +} diff --git a/crates/core/src/auth/accounts/offline.rs b/crates/core/src/auth/accounts/offline.rs new file mode 100644 index 0000000..b82f169 --- /dev/null +++ b/crates/core/src/auth/accounts/offline.rs @@ -0,0 +1,70 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::accounts::{Account, AccountId}; +use crate::auth::authorizers::offline::OFFLINE_AUTHORIZER_ID; +use crate::auth::credential::{Credential, CredentialKind}; +use crate::auth::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OfflineAccount { + pub account_id: AccountId, + pub username: String, + pub uuid: String, +} + +impl OfflineAccount { + pub fn new(username: String) -> Self { + let uuid = offline_uuid(&username); + Self { + account_id: AccountId::derive(OFFLINE_AUTHORIZER_ID, &username), + username, + uuid: uuid.to_string(), + } + } +} + +#[async_trait] +impl Account for OfflineAccount { + fn account_id(&self) -> AccountId { + self.account_id + } + + fn authorizer_id(&self) -> &'static str { + OFFLINE_AUTHORIZER_ID + } + + fn display_name(&self) -> &str { + &self.username + } + + async fn credential(&self) -> Result { + Ok(Credential { + username: self.username.clone(), + uuid: self.uuid.clone(), + access_token: String::new(), + kind: CredentialKind::Legacy, + xuid: None, + client_id: None, + #[cfg(feature = "yggdrasil-auth")] + auth_server: None, + }) + } + + async fn refresh(&self) -> Result> { + Ok(Box::new(self.clone())) + } +} + +fn offline_uuid(username: &str) -> Uuid { + use md5::{Digest, Md5}; + + let offline_name = format!("OfflinePlayer:{username}"); + let mut hasher = Md5::new(); + hasher.update(offline_name); + let mut buffer = hasher.finalize(); + buffer[6] = (buffer[6] & 0x0f) | 0x30; + buffer[8] = (buffer[8] & 0x3f) | 0x80; + Uuid::from_bytes(buffer.into()) +} diff --git a/crates/core/src/auth/accounts/yggdrasil.rs b/crates/core/src/auth/accounts/yggdrasil.rs new file mode 100644 index 0000000..6dc13d7 --- /dev/null +++ b/crates/core/src/auth/accounts/yggdrasil.rs @@ -0,0 +1,207 @@ +use async_trait::async_trait; +use nidhogg::{ + client::Nidhogg, + request::texture::TextureType as NidhoggTextureType, + types::{ + profile::{Profile, ProfilePropertyError}, + textures::{ModelType, Texture}, + }, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth::accounts::{Account, AccountId}; +use crate::auth::authorizers::yggdrasil::{YGGDRASIL_AUTHORIZER_ID, YggdrasilAuthorizer}; +use crate::auth::credential::{Credential, CredentialKind}; +use crate::auth::error::{Error, Result}; +use crate::auth::texture::{ + Cape, ProfileTextures, Skin, SkinModel, TextureType as AccountTextureType, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct YggdrasilProfile { + pub id: String, + pub name: String, +} + +impl From<&Profile> for YggdrasilProfile { + fn from(profile: &Profile) -> Self { + Self { + id: profile.id.clone(), + name: profile.name.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YggdrasilAccount { + pub account_id: AccountId, + pub base_url: String, + pub user_id: Option, + pub preferred_language: Option, + pub access_token: String, + pub client_token: String, + pub selected_profile: YggdrasilProfile, +} + +impl YggdrasilAccount { + fn client(&self) -> Result { + Nidhogg::new(self.base_url.trim_end_matches('/')) + .map_err(|source| Error::Yggdrasil { source }) + } + + async fn upload( + &self, + texture_type: NidhoggTextureType, + image: Vec, + file_name: &str, + model: Option, + ) -> Result<()> { + self.client()? + .upload_texture( + self.selected_profile.id.clone(), + self.access_token.clone(), + texture_type, + image, + file_name.to_owned(), + model, + ) + .await + .map_err(|source| Error::Yggdrasil { source }) + } + + async fn delete(&self, texture_type: NidhoggTextureType) -> Result<()> { + self.client()? + .delete_texture( + self.selected_profile.id.clone(), + self.access_token.clone(), + texture_type, + ) + .await + .map_err(|source| Error::Yggdrasil { source }) + } +} + +#[async_trait] +impl Account for YggdrasilAccount { + fn account_id(&self) -> AccountId { + self.account_id + } + + fn authorizer_id(&self) -> &'static str { + YGGDRASIL_AUTHORIZER_ID + } + + fn display_name(&self) -> &str { + &self.selected_profile.name + } + + async fn credential(&self) -> Result { + Ok(Credential { + username: self.selected_profile.name.clone(), + uuid: self.selected_profile.id.clone(), + access_token: self.access_token.clone(), + kind: CredentialKind::Mojang, + xuid: None, + client_id: None, + auth_server: Some(self.base_url.clone()), + }) + } + + async fn refresh(&self) -> Result> { + Ok(Box::new(YggdrasilAuthorizer::refresh_account(self).await?)) + } + + async fn textures(&self) -> Result { + let profile = self + .client()? + .get_profile(self.selected_profile.id.clone(), false) + .await + .map_err(|source| Error::Yggdrasil { source })?; + profile_textures(&profile) + } + + async fn upload_skin(&self, image: Vec, model: SkinModel) -> Result<()> { + let model = match model { + SkinModel::Classic => None, + SkinModel::Slim => Some(ModelType::SLIM), + }; + self.upload(NidhoggTextureType::SKIN, image, "skin.png", model) + .await + } + + async fn delete_skin(&self) -> Result<()> { + self.delete(NidhoggTextureType::SKIN).await + } + + async fn upload_cape(&self, image: Vec) -> Result<()> { + self.upload(NidhoggTextureType::CAPE, image, "cape.png", None) + .await + } + + async fn delete_cape(&self) -> Result<()> { + self.delete(NidhoggTextureType::CAPE).await + } +} + +fn profile_textures(profile: &Profile) -> Result { + let textures = optional_property(profile.get_textures())?; + let skin = textures + .as_ref() + .and_then(|textures| textures.textures.skin.as_ref()) + .map(skin_from) + .transpose()?; + let capes = textures + .and_then(|textures| textures.textures.cape) + .map(cape_from) + .into_iter() + .collect(); + let uploadable = optional_property(profile.get_uploadable_textures())? + .unwrap_or_default() + .into_iter() + .filter_map(uploadable_texture) + .collect(); + + Ok(ProfileTextures { + skin, + capes, + uploadable, + }) +} + +fn optional_property(result: std::result::Result) -> Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(ProfilePropertyError::NoPropertyFound(_)) => Ok(None), + Err(source) => Err(Error::YggdrasilTexture { source }), + } +} + +fn skin_from(texture: &Texture) -> Result { + let model = match texture.metadata.as_ref().map(|metadata| &metadata.model) { + Some(ModelType::SLIM) => SkinModel::Slim, + Some(ModelType::DEFAULT) | None => SkinModel::Classic, + }; + Ok(Skin { + url: texture.url.clone(), + model, + }) +} + +fn cape_from(texture: Texture) -> Cape { + Cape { + id: None, + alias: None, + url: texture.url, + active: true, + } +} + +fn uploadable_texture(value: String) -> Option { + if value.eq_ignore_ascii_case("skin") { + Some(AccountTextureType::Skin) + } else if value.eq_ignore_ascii_case("cape") { + Some(AccountTextureType::Cape) + } else { + None + } +} diff --git a/crates/core/src/auth/authorizer.rs b/crates/core/src/auth/authorizer.rs deleted file mode 100644 index 5d0e33e..0000000 --- a/crates/core/src/auth/authorizer.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::auth::credential::UserCredential; -use crate::auth::error::Result; - -pub trait Authorizer { - fn authorize(&self) -> impl Future>; - fn name() -> &'static str { - "Authorizer" - } -} diff --git a/crates/core/src/auth/authorizers/microsoft.rs b/crates/core/src/auth/authorizers/microsoft.rs index 3670ae8..37a0d50 100644 --- a/crates/core/src/auth/authorizers/microsoft.rs +++ b/crates/core/src/auth/authorizers/microsoft.rs @@ -1,30 +1,71 @@ -use crate::auth::{ - authorizer::Authorizer, - credential::UserCredential, - error::{Error, Result}, -}; +use crate::auth::accounts::microsoft::{MicrosoftAccount, unix_now}; +use crate::auth::authorizers::Authorizer; +use crate::auth::error::{Error, Result}; +use crate::auth::texture::SkinModel; use minecraft_msa_auth::MinecraftAuthorizationFlow; use oauth2::basic::BasicClient; use oauth2::{ - AuthUrl, ClientId, DeviceAuthorizationUrl, Scope, StandardDeviceAuthorizationResponse, - TokenResponse, TokenUrl, + AuthUrl, ClientId, DeviceAuthorizationUrl, EndpointNotSet, EndpointSet, RefreshToken, Scope, + StandardDeviceAuthorizationResponse, TokenResponse, TokenUrl, }; use oauth2_reqwest::ReqwestClient; use reqwest::Client; use reqwest::ClientBuilder; +use reqwest::multipart::{Form, Part}; use reqwest::redirect::Policy; +use serde::Deserialize; const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; const MSA_AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; const MSA_TOKEN_URL: &str = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; const MINECRAFT_PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile"; +const MINECRAFT_SKIN_UPLOAD_URL: &str = "https://api.minecraftservices.com/minecraft/profile/skins"; +const MINECRAFT_ACTIVE_SKIN_URL: &str = + "https://api.minecraftservices.com/minecraft/profile/skins/active"; +const MINECRAFT_ACTIVE_CAPE_URL: &str = + "https://api.minecraftservices.com/minecraft/profile/capes/active"; +const XBOX_USER_AUTHENTICATE_URL: &str = "https://user.auth.xboxlive.com/user/authenticate"; + +pub const MICROSOFT_AUTHORIZER_ID: &str = "microsoft"; + +#[derive(Debug, Deserialize)] +pub(crate) struct MinecraftProfileResponse { + pub(crate) id: String, + pub(crate) name: String, + #[serde(default)] + pub(crate) skins: Vec, + #[serde(default)] + pub(crate) capes: Vec, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct MinecraftTexture { + pub(crate) id: Option, + pub(crate) state: String, + pub(crate) url: String, + pub(crate) variant: Option, + pub(crate) alias: Option, +} -#[derive(serde::Deserialize)] -struct MinecraftProfileResponse { - id: String, - name: String, +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct XboxUserAuthenticateResponse { + display_claims: XboxDisplayClaims, } +#[derive(Debug, Deserialize)] +struct XboxDisplayClaims { + xui: Vec, +} + +#[derive(Debug, Deserialize)] +struct XboxUserClaim { + xid: String, +} + +type RefreshClient = + BasicClient; + /// Result returned by [`MicrosoftAuthorizer::verification_handler`]. pub type VerificationHandlerResult = std::result::Result<(), Box>; @@ -47,29 +88,14 @@ impl VerificationHandlerResult> MicrosoftAuthorizer } impl VerificationHandlerResult> Authorizer for MicrosoftAuthorizer { - async fn authorize(&self) -> Result { + type Account = MicrosoftAccount; + + async fn authorize(&self) -> Result { let reqwest_client = Self::client()?; let client = BasicClient::new(ClientId::new(self.client_id.clone())) - .set_auth_uri( - AuthUrl::new(MSA_AUTHORIZE_URL.to_string()).map_err(|source| Error::Endpoint { - endpoint: "authorize", - source, - })?, - ) - .set_token_uri(TokenUrl::new(MSA_TOKEN_URL.to_string()).map_err(|source| { - Error::Endpoint { - endpoint: "token", - source, - } - })?) - .set_device_authorization_url( - DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_string()).map_err(|source| { - Error::Endpoint { - endpoint: "device authorization", - source, - } - })?, - ); + .set_auth_uri(auth_url()?) + .set_token_uri(token_url()?) + .set_device_authorization_url(device_authorization_url()?); let details: StandardDeviceAuthorizationResponse = client .exchange_device_code() @@ -90,32 +116,212 @@ impl VerificationHandlerResult> Authorizer for Microsof .request_async(&reqwest_client, tokio::time::sleep, None) .await .map_err(|source| Error::DeviceTokenRequest { source })?; + let refresh_token = token + .refresh_token() + .ok_or(Error::MicrosoftRefreshTokenMissing)? + .secret() + .to_owned(); - let mc_flow = MinecraftAuthorizationFlow::new(Client::new()); - let mc_token = mc_flow - .exchange_microsoft_token(token.access_token().secret()) - .await - .map_err(|source| Error::MinecraftExchange { source })?; - let profile = reqwest::Client::new() - .get(MINECRAFT_PROFILE_URL) - .bearer_auth(mc_token.access_token().as_ref()) - .send() - .await - .map_err(|source| Error::ProfileRequest { source })? - .error_for_status() - .map_err(|source| Error::ProfileStatus { source })? - .json::() - .await - .map_err(|source| Error::ProfileDecode { source })?; + account_from_microsoft_token( + &self.client_id, + token.access_token().secret(), + refresh_token, + ) + .await + } - Ok(UserCredential { - username: profile.name, - uuid: profile.id, - access_token: mc_token.access_token().clone().into_inner(), - }) + fn id() -> &'static str { + MICROSOFT_AUTHORIZER_ID } fn name() -> &'static str { "Microsoft" } } + +pub(crate) async fn refresh(account: &MicrosoftAccount) -> Result { + let reqwest_client = + MicrosoftAuthorizer:: VerificationHandlerResult>::client()?; + let client = refresh_client(&account.client_id)?; + let token = client + .exchange_refresh_token(&RefreshToken::new(account.refresh_token.clone())) + .request_async(&reqwest_client) + .await + .map_err(|source| Error::RefreshTokenRequest { source })?; + let refresh_token = token + .refresh_token() + .map(|token| token.secret().to_owned()) + .unwrap_or_else(|| account.refresh_token.clone()); + + account_from_microsoft_token( + &account.client_id, + token.access_token().secret(), + refresh_token, + ) + .await +} + +fn refresh_client(client_id: &str) -> Result { + Ok(BasicClient::new(ClientId::new(client_id.to_owned())).set_token_uri(token_url()?)) +} + +fn auth_url() -> Result { + AuthUrl::new(MSA_AUTHORIZE_URL.to_owned()).map_err(|source| Error::Endpoint { + endpoint: "authorize", + source, + }) +} + +fn token_url() -> Result { + TokenUrl::new(MSA_TOKEN_URL.to_owned()).map_err(|source| Error::Endpoint { + endpoint: "token", + source, + }) +} + +fn device_authorization_url() -> Result { + DeviceAuthorizationUrl::new(DEVICE_CODE_URL.to_owned()).map_err(|source| Error::Endpoint { + endpoint: "device authorization", + source, + }) +} + +async fn account_from_microsoft_token( + client_id: &str, + microsoft_access_token: &str, + refresh_token: String, +) -> Result { + let mc_flow = MinecraftAuthorizationFlow::new(Client::new()); + let mc_token = mc_flow + .exchange_microsoft_token(microsoft_access_token) + .await + .map_err(|source| Error::MinecraftExchange { source })?; + let profile = minecraft_profile(mc_token.access_token().as_ref()).await?; + let xuid = fetch_xuid(microsoft_access_token).await?; + + Ok(MicrosoftAccount { + account_id: crate::auth::accounts::AccountId::derive(MICROSOFT_AUTHORIZER_ID, &profile.id), + username: profile.name, + uuid: profile.id, + access_token: mc_token.access_token().clone().into_inner(), + refresh_token, + client_id: client_id.to_owned(), + xuid: Some(xuid), + expires_at: unix_now().saturating_add(u64::from(mc_token.expires_in())), + }) +} + +pub(crate) async fn minecraft_profile(access_token: &str) -> Result { + Client::new() + .get(MINECRAFT_PROFILE_URL) + .bearer_auth(access_token) + .send() + .await + .map_err(|source| Error::ProfileRequest { source })? + .error_for_status() + .map_err(|source| Error::ProfileStatus { source })? + .json::() + .await + .map_err(|source| Error::ProfileDecode { source }) +} + +pub(crate) async fn upload_skin( + access_token: &str, + image: Vec, + model: SkinModel, +) -> Result<()> { + let variant = match model { + SkinModel::Classic => "classic", + SkinModel::Slim => "slim", + }; + let file = Part::bytes(image) + .file_name("skin.png") + .mime_str("image/png") + .map_err(|source| Error::TextureRequest { + operation: "upload skin", + source, + })?; + let form = Form::new().text("variant", variant).part("file", file); + send_texture_request( + Client::new() + .post(MINECRAFT_SKIN_UPLOAD_URL) + .bearer_auth(access_token) + .multipart(form), + "upload skin", + ) + .await +} + +pub(crate) async fn delete_skin(access_token: &str) -> Result<()> { + send_texture_request( + Client::new() + .delete(MINECRAFT_ACTIVE_SKIN_URL) + .bearer_auth(access_token), + "delete skin", + ) + .await +} + +pub(crate) async fn select_cape(access_token: &str, cape_id: Option) -> Result<()> { + let request = match cape_id { + Some(cape_id) => Client::new() + .put(MINECRAFT_ACTIVE_CAPE_URL) + .json(&serde_json::json!({ "capeId": cape_id })), + None => Client::new().delete(MINECRAFT_ACTIVE_CAPE_URL), + }; + send_texture_request(request.bearer_auth(access_token), "select cape").await +} + +async fn send_texture_request( + request: reqwest::RequestBuilder, + operation: &'static str, +) -> Result<()> { + let response = request + .send() + .await + .map_err(|source| Error::TextureRequest { operation, source })?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + + let body = response + .text() + .await + .map_err(|source| Error::TextureRequest { operation, source })?; + Err(Error::TextureStatus { + operation, + status, + body, + }) +} + +async fn fetch_xuid(microsoft_access_token: &str) -> Result { + let response = Client::new() + .post(XBOX_USER_AUTHENTICATE_URL) + .json(&serde_json::json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={microsoft_access_token}"), + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + })) + .send() + .await + .map_err(|source| Error::XuidRequest { source })? + .error_for_status() + .map_err(|source| Error::XuidRequest { source })? + .json::() + .await + .map_err(|source| Error::XuidDecode { source })?; + + response + .display_claims + .xui + .into_iter() + .next() + .map(|claim| claim.xid) + .ok_or(Error::XuidMissing) +} diff --git a/crates/core/src/auth/authorizers/mod.rs b/crates/core/src/auth/authorizers/mod.rs index d345a2a..df274a4 100644 --- a/crates/core/src/auth/authorizers/mod.rs +++ b/crates/core/src/auth/authorizers/mod.rs @@ -1,2 +1,20 @@ pub mod microsoft; pub mod offline; +#[cfg(feature = "yggdrasil-auth")] +pub mod yggdrasil; + +use crate::auth::{accounts::Account, error::Result}; + +pub trait Authorizer { + type Account: Account; + + /// Stable persistence key. This must not change once accounts are stored. + fn id() -> &'static str; + + /// Human-readable name for user interfaces. + fn name() -> &'static str { + "Authorizer" + } + + fn authorize(&self) -> impl Future>; +} diff --git a/crates/core/src/auth/authorizers/offline.rs b/crates/core/src/auth/authorizers/offline.rs index 9b16c69..f18348b 100644 --- a/crates/core/src/auth/authorizers/offline.rs +++ b/crates/core/src/auth/authorizers/offline.rs @@ -1,25 +1,22 @@ -use crate::auth::{authorizer::Authorizer, credential::UserCredential, error::Result}; -use md5::{Digest, Md5}; -use uuid::Uuid; +use crate::auth::accounts::offline::OfflineAccount; +use crate::auth::authorizers::Authorizer; +use crate::auth::error::Result; + +pub const OFFLINE_AUTHORIZER_ID: &str = "offline"; pub struct OfflineAuthorizer { pub username: String, } impl Authorizer for OfflineAuthorizer { - async fn authorize(&self) -> Result { - let offline_name = format!("OfflinePlayer:{}", self.username); - let mut hasher = Md5::new(); - hasher.update(offline_name); - let mut buffer = hasher.finalize(); - buffer[6] = (buffer[6] & 0x0f) | 0x30; - buffer[8] = (buffer[8] & 0x3f) | 0x80; + type Account = OfflineAccount; + + async fn authorize(&self) -> Result { + Ok(OfflineAccount::new(self.username.clone())) + } - Ok(UserCredential { - username: self.username.clone(), - uuid: Uuid::from_bytes(buffer.into()).to_string(), - access_token: "".to_string(), - }) + fn id() -> &'static str { + OFFLINE_AUTHORIZER_ID } fn name() -> &'static str { diff --git a/crates/core/src/auth/authorizers/yggdrasil.rs b/crates/core/src/auth/authorizers/yggdrasil.rs new file mode 100644 index 0000000..bcd50b5 --- /dev/null +++ b/crates/core/src/auth/authorizers/yggdrasil.rs @@ -0,0 +1,129 @@ +use crate::auth::accounts::AccountId; +use crate::auth::accounts::yggdrasil::{YggdrasilAccount, YggdrasilProfile}; +use crate::auth::authorizers::Authorizer; +use crate::auth::error::{Error, Result}; +use nidhogg::{ + client::Nidhogg, + request::{authenticate::Authenticate, refresh::Refresh}, + types::{profile::Profile, user::User}, +}; + +pub const YGGDRASIL_AUTHORIZER_ID: &str = "yggdrasil"; + +pub struct YggdrasilAuthorizer { + pub base_url: String, + pub username: String, + pub password: String, + pub client_token: Option, +} + +impl YggdrasilAuthorizer { + fn client(&self) -> Result { + Self::client_for(&self.base_url) + } + + fn client_for(base_url: &str) -> Result { + Nidhogg::new(base_url.trim_end_matches('/')).map_err(|source| Error::Yggdrasil { source }) + } + + pub async fn authenticate(&self) -> Result { + let base_url = normalized_base_url(&self.base_url); + let response = self + .client()? + .authenticate(&Authenticate::new( + self.username.clone(), + self.password.clone(), + self.client_token.clone(), + true, + )) + .await + .map_err(|source| Error::Yggdrasil { source })?; + let selected_profile = response + .selected_profile + .as_ref() + .or_else(|| { + response + .available_profiles + .as_ref() + .and_then(|profiles| profiles.first()) + }) + .ok_or(Error::YggdrasilProfileMissing)?; + + Ok(YggdrasilAccount { + base_url: base_url.to_owned(), + account_id: account_id(base_url, &selected_profile.id), + user_id: user_id(response.user.as_ref()), + preferred_language: preferred_language(response.user.as_ref()), + access_token: response.access_token, + client_token: response.client_token, + selected_profile: YggdrasilProfile::from(selected_profile), + }) + } + + pub async fn refresh_account(account: &YggdrasilAccount) -> Result { + let response = Self::client_for(&account.base_url)? + .refresh(&Refresh::new( + account.access_token.clone(), + Some(account.client_token.clone()), + true, + Some(Profile { + id: account.selected_profile.id.clone(), + name: account.selected_profile.name.clone(), + properties: None, + }), + )) + .await + .map_err(|source| Error::Yggdrasil { source })?; + + Ok(YggdrasilAccount { + account_id: account.account_id, + base_url: account.base_url.clone(), + user_id: user_id(response.user.as_ref()).or_else(|| account.user_id.clone()), + preferred_language: preferred_language(response.user.as_ref()) + .or_else(|| account.preferred_language.clone()), + access_token: response.access_token, + client_token: response.client_token, + selected_profile: response + .selected_profile + .as_ref() + .map(YggdrasilProfile::from) + .unwrap_or_else(|| account.selected_profile.clone()), + }) + } + + pub async fn refresh(&self, account: &YggdrasilAccount) -> Result { + Self::refresh_account(account).await + } +} + +fn normalized_base_url(base_url: &str) -> &str { + base_url.trim_end_matches('/') +} + +fn account_id(base_url: &str, profile_id: &str) -> AccountId { + AccountId::derive(YGGDRASIL_AUTHORIZER_ID, &format!("{base_url}:{profile_id}")) +} + +fn user_id(user: Option<&User>) -> Option { + user.map(|user| user.id.clone()) +} + +fn preferred_language(user: Option<&User>) -> Option { + user.and_then(User::get_preferred_language).cloned() +} + +impl Authorizer for YggdrasilAuthorizer { + type Account = YggdrasilAccount; + + async fn authorize(&self) -> Result { + self.authenticate().await + } + + fn id() -> &'static str { + YGGDRASIL_AUTHORIZER_ID + } + + fn name() -> &'static str { + "Yggdrasil" + } +} diff --git a/crates/core/src/auth/credential.rs b/crates/core/src/auth/credential.rs index aa93d34..40f3ae4 100644 --- a/crates/core/src/auth/credential.rs +++ b/crates/core/src/auth/credential.rs @@ -1,6 +1,34 @@ -#[derive(Debug, Clone)] -pub struct UserCredential { +use serde::{Deserialize, Serialize}; + +/// Credential passed to the game process for one launch. +/// +/// This value is derived from an [`crate::auth::accounts::Account`] and is +/// intentionally not persisted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credential { pub username: String, pub uuid: String, pub access_token: String, + pub kind: CredentialKind, + pub xuid: Option, + pub client_id: Option, + /// Yggdrasil API root for authlib-injector. + #[cfg(feature = "yggdrasil-auth")] + pub auth_server: Option, +} + +impl Credential { + #[cfg(feature = "yggdrasil-auth")] + pub fn with_auth_server(mut self, auth_server: impl Into) -> Self { + self.auth_server = Some(auth_server.into()); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialKind { + Legacy, + Mojang, + Msa, } diff --git a/crates/core/src/auth/error.rs b/crates/core/src/auth/error.rs index 5ca7a31..3296d93 100644 --- a/crates/core/src/auth/error.rs +++ b/crates/core/src/auth/error.rs @@ -36,6 +36,12 @@ pub enum Error { source: DeviceTokenRequestError, }, + #[error("refresh Microsoft access token failed")] + RefreshTokenRequest { + #[source] + source: DeviceCodeRequestError, + }, + #[error("exchange Microsoft token for Minecraft token failed")] MinecraftExchange { #[source] @@ -54,17 +60,73 @@ pub enum Error { source: reqwest::Error, }, + #[error("Microsoft OAuth response contains no refresh token")] + MicrosoftRefreshTokenMissing, + + #[error("request Xbox XUID failed")] + XuidRequest { + #[source] + source: reqwest::Error, + }, + + #[error("decode Xbox XUID response failed")] + XuidDecode { + #[source] + source: reqwest::Error, + }, + + #[error("Xbox XUID response contains no XUID")] + XuidMissing, + #[error("decode Minecraft profile response failed")] ProfileDecode { #[source] source: reqwest::Error, }, + #[error("texture operation '{operation}' is not supported for this account")] + TextureUnsupported { operation: &'static str }, + + #[error("send texture request for '{operation}' failed")] + TextureRequest { + operation: &'static str, + #[source] + source: reqwest::Error, + }, + + #[error("texture request for '{operation}' returned {status}: {body}")] + TextureStatus { + operation: &'static str, + status: reqwest::StatusCode, + body: String, + }, + + #[error("unknown skin model '{model}'")] + TextureModelUnknown { model: String }, + #[error("verification handler failed")] VerificationHandler { #[source] source: Box, }, + + #[cfg(feature = "yggdrasil-auth")] + #[error("Yggdrasil request failed")] + Yggdrasil { + #[source] + source: nidhogg::error::NidhoggError, + }, + + #[cfg(feature = "yggdrasil-auth")] + #[error("Yggdrasil authentication response contains no Minecraft profile")] + YggdrasilProfileMissing, + + #[cfg(feature = "yggdrasil-auth")] + #[error("parse Yggdrasil profile textures failed")] + YggdrasilTexture { + #[source] + source: nidhogg::types::profile::ProfilePropertyError, + }, } pub type Result = std::result::Result; diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 203f709..04cb5e4 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -1,6 +1,10 @@ -pub mod authorizer; +pub mod accounts; pub mod authorizers; pub mod credential; pub mod error; +pub mod texture; pub use error::{Error, Result}; + +#[cfg(feature = "offline-server")] +pub use nidhogg::server; diff --git a/crates/core/src/auth/texture.rs b/crates/core/src/auth/texture.rs new file mode 100644 index 0000000..decd455 --- /dev/null +++ b/crates/core/src/auth/texture.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +/// Texture kinds that can be read from or changed on a Minecraft profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TextureType { + Skin, + Cape, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SkinModel { + #[default] + Classic, + Slim, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Skin { + pub url: String, + pub model: SkinModel, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Cape { + /// Selectable cape identifier when the provider exposes a cape inventory. + pub id: Option, + pub alias: Option, + pub url: String, + pub active: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileTextures { + pub skin: Option, + /// Capes available for selection. Yggdrasil reports only the active texture. + pub capes: Vec, + /// Texture kinds this account accepts through upload. + pub uploadable: Vec, +} + +impl ProfileTextures { + pub fn active_cape(&self) -> Option<&Cape> { + self.capes.iter().find(|cape| cape.active) + } +} diff --git a/crates/driver/src/families/version_json/builder.rs b/crates/driver/src/families/version_json/builder.rs index 7fc15e2..4699638 100644 --- a/crates/driver/src/families/version_json/builder.rs +++ b/crates/driver/src/families/version_json/builder.rs @@ -3,7 +3,7 @@ mod command; mod paths; use elemental_core::{ - auth::credential::UserCredential, runtime::distribution::Distribution, storage::Storage, + auth::credential::Credential, runtime::distribution::Distribution, storage::Storage, }; use elemental_schema::mojang::piston::PistonMetaData; @@ -19,7 +19,7 @@ use super::{ pub(crate) use paths::resolve_absolute_path; pub struct VersionJsonLaunchBuilder { - pub credential: UserCredential, + pub credential: Credential, pub runtime: Distribution, pub version: Storage>, inner: LauncherVariables, @@ -29,7 +29,7 @@ pub struct VersionJsonLaunchBuilder VersionJsonLaunchBuilder { pub fn new( - credential: UserCredential, + credential: Credential, runtime: Distribution, version: Storage>, inner: LauncherVariables, diff --git a/crates/driver/src/families/version_json/builder/command.rs b/crates/driver/src/families/version_json/builder/command.rs index 85b1886..30c4afd 100644 --- a/crates/driver/src/families/version_json/builder/command.rs +++ b/crates/driver/src/families/version_json/builder/command.rs @@ -1,5 +1,8 @@ use elemental_core::storage::layout::Layoutable; -use elemental_core::{auth::credential::UserCredential, launcher::command::LaunchCommand}; +use elemental_core::{ + auth::credential::{Credential, CredentialKind}, + launcher::command::LaunchCommand, +}; use elemental_schema::mojang::piston::PistonMetaData; use crate::{ @@ -63,7 +66,7 @@ where fn apply_default_variables( &mut self, metadata: &PistonMetaData, - credential: &UserCredential, + credential: &Credential, version_name: String, paths: &LaunchPaths, ) { @@ -77,11 +80,17 @@ where self.inner.assets_index_name = metadata.assets.clone(); self.inner.auth_uuid = credential.uuid.clone(); self.inner.auth_access_token = credential.access_token.clone(); - self.inner.user_type = if self.inner.auth_access_token.is_empty() { - UserType::Legacy - } else { - UserType::Msa + self.inner.user_type = match credential.kind { + CredentialKind::Legacy => UserType::Legacy, + CredentialKind::Mojang => UserType::Mojang, + CredentialKind::Msa => UserType::Msa, }; + if self.inner.clientid.is_empty() { + self.inner.clientid = credential.client_id.clone().unwrap_or_default(); + } + if self.inner.auth_xuid.is_empty() { + self.inner.auth_xuid = credential.xuid.clone().unwrap_or_default(); + } self.inner.version_type = metadata.release_type.clone(); self.inner.library_directory = paths.libraries_root.to_string_lossy().to_string(); self.inner.classpath_separator = classpath_separator().to_owned(); diff --git a/crates/elemental/Cargo.toml b/crates/elemental/Cargo.toml index 2016c01..227c3f4 100644 --- a/crates/elemental/Cargo.toml +++ b/crates/elemental/Cargo.toml @@ -28,6 +28,9 @@ shared-toml = ["shared", "elemental-shared/toml"] driver = ["dep:elemental-driver", "core", "infra", "schema", "shared"] launcher = ["dep:elemental-launcher", "core", "driver", "infra"] +yggdrasil-auth = ["core", "launcher", "elemental-core/yggdrasil-auth", "elemental-launcher/yggdrasil-auth"] +authlib-injector-embed = ["yggdrasil-auth", "elemental-launcher/authlib-injector-embed"] +offline-server = ["yggdrasil-auth", "elemental-launcher/offline-server"] object = ["dep:elemental-object"] object-notify = ["object", "elemental-object/notify"] diff --git a/crates/elemental/src/lib.rs b/crates/elemental/src/lib.rs index 4c31d4f..044b758 100644 --- a/crates/elemental/src/lib.rs +++ b/crates/elemental/src/lib.rs @@ -22,8 +22,14 @@ pub mod prelude { #[cfg(feature = "core")] pub use crate::core::{ auth::{ - authorizer::Authorizer, authorizers::offline::OfflineAuthorizer, - credential::UserCredential, + accounts::{Account, AccountId, microsoft::MicrosoftAccount, offline::OfflineAccount}, + authorizers::{ + Authorizer, + microsoft::{MICROSOFT_AUTHORIZER_ID, MicrosoftAuthorizer}, + offline::{OFFLINE_AUTHORIZER_ID, OfflineAuthorizer}, + }, + credential::{Credential, CredentialKind}, + texture::{Cape, ProfileTextures, Skin, SkinModel, TextureType}, }, launcher::command::LaunchCommand, minecraft::MinecraftVersionId, @@ -34,6 +40,12 @@ pub mod prelude { }, }; + #[cfg(feature = "yggdrasil-auth")] + pub use crate::core::auth::{ + accounts::yggdrasil::{YggdrasilAccount, YggdrasilProfile}, + authorizers::yggdrasil::{YGGDRASIL_AUTHORIZER_ID, YggdrasilAuthorizer}, + }; + #[cfg(feature = "driver")] pub use crate::driver::{ catalog::{GameVersion, LoaderRelease}, @@ -49,9 +61,9 @@ pub mod prelude { #[cfg(feature = "launcher")] pub use crate::launcher::{ - DriverCatalog, DriverOrigins, DriverSpec, Instance, LaunchOptions, LaunchResolution, - LaunchedInstance, Launcher, LauncherBuilder, LoaderSpec, PreparedInstance, - PreparedInstanceInfo, PreparedInstanceMetadata, QuickPlayOptions, RuntimeOptions, - VanillaSpec, + AccountIndex, AccountRef, AccountRegistry, AccountStore, DriverCatalog, DriverOrigins, + DriverSpec, Instance, LaunchOptions, LaunchResolution, LaunchedInstance, Launcher, + LauncherBuilder, LoaderSpec, PreparedInstance, PreparedInstanceInfo, + PreparedInstanceMetadata, QuickPlayOptions, RuntimeOptions, VanillaSpec, }; } diff --git a/crates/launcher/Cargo.toml b/crates/launcher/Cargo.toml index ecc3ef6..2695fa3 100644 --- a/crates/launcher/Cargo.toml +++ b/crates/launcher/Cargo.toml @@ -15,3 +15,22 @@ tokio = { workspace = true } elemental-core = { path = "../core", version = "0.2.0" } elemental-driver = { path = "../driver", version = "0.2.0" } elemental-infra = { path = "../infra", version = "0.2.0" } +elemental-shared = { path = "../shared", version = "0.2.0" } +erased-serde = { workspace = true } +hex = { workspace = true, optional = true } +nidhogg = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true, optional = true } + +[features] +default = [] +yggdrasil-auth = [ + "elemental-core/yggdrasil-auth", + "dep:hex", + "dep:nidhogg", + "dep:sha2", + "nidhogg/authlib-injector", +] +authlib-injector-embed = ["yggdrasil-auth", "nidhogg/authlib-injector-embed"] +offline-server = ["elemental-core/offline-server", "yggdrasil-auth"] diff --git a/crates/launcher/README.md b/crates/launcher/README.md index 1080ac6..d9878db 100644 --- a/crates/launcher/README.md +++ b/crates/launcher/README.md @@ -36,13 +36,24 @@ tokio = { version = "1", features = ["macros", "process", "rt-multi-thread"] } `elemental-core` is needed only for the authorizer used in the example. A real application can supply its own `Authorizer` implementation. +Enable `yggdrasil-auth` to install and inject authlib-injector for any +Yggdrasil-compatible credential. Enable `offline-server` to additionally +re-export nidhogg's local Yggdrasil server: + +```toml +elemental-launcher = { version = "0.2", features = ["offline-server"] } +``` + ## Quick Start Prepare a Vanilla instance and build its launch command: ```rust use anyhow::Result; -use elemental_core::auth::{authorizer::Authorizer, authorizers::offline::OfflineAuthorizer}; +use elemental_core::auth::{ + accounts::Account, + authorizers::{Authorizer, offline::OfflineAuthorizer}, +}; use elemental_launcher::{DriverSpec, LaunchOptions, Launcher, RuntimeOptions}; #[tokio::main(flavor = "multi_thread")] @@ -59,11 +70,12 @@ async fn main() -> Result<()> { ) .await?; - let credential = OfflineAuthorizer { + let account = OfflineAuthorizer { username: "Player".to_owned(), } .authorize() .await?; + let credential = account.credential().await?; let mut command = launcher .build_launch_command( @@ -100,9 +112,61 @@ println!( ``` Authentication is independent from launch. An `Authorizer` returns a -`UserCredential`; `build_launch_command()` injects that credential into the -game arguments and returns a mutable `LaunchCommand`. Authentication-specific -JVM arguments can be appended after that without changing the credential type. +`Account`; `Account::credential()` produces the `Credential` that +`build_launch_command()` injects into the game arguments and returns a mutable +`LaunchCommand`. Authentication-specific JVM arguments can be appended after +that without changing the credential type. + +### Accounts + +`AccountStore` persists accounts under `.elemental/accounts`: + +- `index.json` lists `AccountRef` entries and the selected account. +- `/.json` stores one authorizer-specific account. + +`Authorizer::id()` is the stable persistence key, so it must not change once +accounts exist. `Authorizer::name()` is only for user interfaces. Accounts are +persisted and refreshable; `Credential` is derived per launch and is +never written to disk. With `yggdrasil-auth`, Yggdrasil credentials +carry their API root in `Credential::auth_server` for authlib-injector. + +Accounts are serializable domain models; the launcher owns when and where they +are persisted. Third-party authorizers register their account type through +`AccountRegistry`, then open the store with `AccountStore::with_registry()`. + +### Textures + +`AccountStore` refreshes an account when needed before texture operations: + +```rust +use elemental_core::auth::accounts::Account; +use elemental_launcher::SkinModel; + +let selected = store.selected().await?.expect("no selected account"); +let account = store.account(selected.account_id()).await?; +let textures = account.textures().await?; +if let Some(skin) = &textures.skin { + println!("skin: {} ({:?})", skin.url, skin.model); +} +for cape in &textures.capes { + println!("cape: {:?} active={}", cape.alias, cape.active); +} + +let image_bytes = std::fs::read("skin.png")?; +account + .upload_skin(image_bytes, SkinModel::Slim) + .await?; +if let Some(cape_id) = textures.capes.iter().find_map(|cape| cape.id.clone()) { + account.select_cape(Some(cape_id)).await?; +} +``` + +Microsoft accounts read skins and owned capes from Minecraft Services and +support skin upload, skin reset, cape selection, and cape clearing. Yggdrasil +accounts read `textures` and `uploadableTextures` from their session server and +support skin or cape upload and deletion when the server advertises the +capability. `ProfileTextures::uploadable` tells the UI which upload actions to +offer; `ProfileTextures::active_cape()` returns the selected cape. ### Observing preparation progress @@ -162,24 +226,47 @@ let prepared = launcher The session exposes `progress_receiver()`, `subscribe()`, and `snapshot()` for UI progress reporting. -### Auth-specific JVM arguments +### Offline server and authlib-injector + +With the `offline-server` feature, `elemental_launcher::offline_server` +re-exports nidhogg's local Yggdrasil service: ```rust -let mut command = launcher - .build_launch_command( - &prepared, - &credential, - &RuntimeOptions::default(), - &LaunchOptions::default(), +use std::net::{Ipv4Addr, SocketAddr}; + +use elemental_launcher::offline_server::{OfflineProfile, OfflineServer}; + +let server = OfflineServer::builder() + .server_name("Local".to_owned()) + .profile( + OfflineProfile::new("Player".to_owned()) + .with_skin("skin.png".into()) + .with_cape("cape.png".into()), ) - .await?; -command.add_jvm_argument(format!( - "-javaagent:{}", - authlib_injector.display() -)); -let game = launcher.launch(command)?; + .start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?; + +let credential = account + .credential() + .await? + .with_auth_server(server.base_url.to_string()); ``` +When `Credential::auth_server` is set and the `yggdrasil-auth` feature is +enabled (included by `offline-server`), `build_launch_command()` installs +authlib-injector to `.elemental/authlib-injector/authlib-injector.jar` and adds: + +```text +-javaagent:= +-Dauthlibinjector.side=client +``` + +The latest artifact metadata is fetched at runtime, and downloads are verified +against the SHA-256 checksum in that metadata. Enable `authlib-injector-embed` +to include a build-time snapshot; metadata, download, or verification failures +then fall back to the embedded JAR. Without that feature, failures are returned +to the caller. Call `launcher.install_authlib_injector().await?` to install it +before launch. + ## Instance Lifecycle | Operation | Method | diff --git a/crates/launcher/src/account/index.rs b/crates/launcher/src/account/index.rs new file mode 100644 index 0000000..bf8e086 --- /dev/null +++ b/crates/launcher/src/account/index.rs @@ -0,0 +1,58 @@ +use elemental_core::auth::accounts::AccountId; +use elemental_shared::{ + migrator::NoMigrator, + persistor::JsonFilePersistor, + store::{Store, StoreLoader}, +}; +use serde::{Deserialize, Serialize}; + +pub(super) const ACCOUNT_INDEX_VERSION: usize = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountRef { + pub account_id: AccountId, + pub authorizer: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AccountIndex { + pub selected: Option, + pub accounts: Vec, +} + +impl AccountIndex { + pub(super) fn upsert(&mut self, reference: AccountRef) { + match self + .accounts + .iter_mut() + .find(|stored| stored.account_id == reference.account_id) + { + Some(stored) => *stored = reference.clone(), + None => self.accounts.push(reference.clone()), + } + + if self + .selected + .as_ref() + .is_some_and(|selected| selected.account_id == reference.account_id) + { + self.selected = Some(reference); + } + } + + pub(super) fn remove(&mut self, account_id: AccountId) { + self.accounts + .retain(|stored| stored.account_id != account_id); + self.selected = self + .selected + .take() + .filter(|selected| selected.account_id != account_id); + } +} + +pub(super) type AccountIndexStore = StoreLoader< + NoMigrator, + Store, + AccountIndex, + JsonFilePersistor>, +>; diff --git a/crates/launcher/src/account/mod.rs b/crates/launcher/src/account/mod.rs new file mode 100644 index 0000000..a2b6567 --- /dev/null +++ b/crates/launcher/src/account/mod.rs @@ -0,0 +1,7 @@ +mod index; +mod registry; +mod store; + +pub use index::{AccountIndex, AccountRef}; +pub use registry::AccountRegistry; +pub use store::AccountStore; diff --git a/crates/launcher/src/account/registry.rs b/crates/launcher/src/account/registry.rs new file mode 100644 index 0000000..a3787fa --- /dev/null +++ b/crates/launcher/src/account/registry.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use elemental_core::auth::{ + accounts::{Account, microsoft::MicrosoftAccount, offline::OfflineAccount}, + authorizers::{microsoft::MICROSOFT_AUTHORIZER_ID, offline::OFFLINE_AUTHORIZER_ID}, +}; +use serde::{Serialize, de::DeserializeOwned}; + +#[cfg(feature = "yggdrasil-auth")] +use elemental_core::auth::{ + accounts::yggdrasil::YggdrasilAccount, authorizers::yggdrasil::YGGDRASIL_AUTHORIZER_ID, +}; + +pub(super) type AccountDecoder = + fn(&str) -> std::result::Result, serde_json::Error>; + +/// Maps stable authorizer IDs to the account types they persist. +#[derive(Clone)] +pub struct AccountRegistry { + decoders: HashMap<&'static str, AccountDecoder>, +} + +impl AccountRegistry { + pub fn new() -> Self { + let registry = Self::empty() + .with::(OFFLINE_AUTHORIZER_ID) + .with::(MICROSOFT_AUTHORIZER_ID); + #[cfg(feature = "yggdrasil-auth")] + let registry = registry.with::(YGGDRASIL_AUTHORIZER_ID); + registry + } + + pub fn empty() -> Self { + Self { + decoders: HashMap::new(), + } + } + + pub fn with(mut self, authorizer_id: &'static str) -> Self + where + A: Account + DeserializeOwned + 'static, + { + self.register::(authorizer_id); + self + } + + pub fn register(&mut self, authorizer_id: &'static str) + where + A: Account + DeserializeOwned + 'static, + { + self.decoders.insert(authorizer_id, decode_account::); + } + + pub(super) fn decoder(&self, authorizer: &str) -> Option { + self.decoders.get(authorizer).copied() + } +} + +impl Default for AccountRegistry { + fn default() -> Self { + Self::new() + } +} + +struct SerializableAccount<'a>(&'a dyn Account); + +impl Serialize for SerializableAccount<'_> { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + erased_serde::serialize(self.0, serializer) + } +} + +pub(super) fn encode_account( + account: &dyn Account, +) -> std::result::Result { + serde_json::to_string(&SerializableAccount(account)) +} + +fn decode_account(data: &str) -> std::result::Result, serde_json::Error> +where + A: Account + DeserializeOwned + 'static, +{ + serde_json::from_str::(data).map(|account| Box::new(account) as Box) +} diff --git a/crates/launcher/src/account/store.rs b/crates/launcher/src/account/store.rs new file mode 100644 index 0000000..5b87e94 --- /dev/null +++ b/crates/launcher/src/account/store.rs @@ -0,0 +1,207 @@ +use std::{io::ErrorKind, path::PathBuf}; + +use elemental_core::auth::{ + accounts::{Account, AccountId}, + authorizers::Authorizer, +}; +use elemental_shared::{ + migrator::NoMigrator, persistor::json_persistor, scope::Scope, store::Store, +}; + +use super::{ + index::{ACCOUNT_INDEX_VERSION, AccountIndex, AccountIndexStore, AccountRef}, + registry::{AccountDecoder, AccountRegistry, encode_account}, +}; +use crate::error::{Error, Result}; + +pub struct AccountStore { + scope: Scope, + index: AccountIndexStore, + registry: AccountRegistry, +} + +impl AccountStore { + pub async fn open(scope: Scope) -> Result { + Self::with_registry(scope, AccountRegistry::new()).await + } + + pub async fn with_registry(scope: Scope, registry: AccountRegistry) -> Result { + let index = Store::load( + NoMigrator, + json_persistor("accounts/index".to_owned(), scope.clone()), + ACCOUNT_INDEX_VERSION, + ) + .await?; + + Ok(Self { + scope, + index, + registry, + }) + } + + pub async fn index(&self) -> AccountIndex { + self.index.cloned().await.value + } + + fn decoder(&self, reference: &AccountRef) -> Result { + self.registry + .decoder(&reference.authorizer) + .ok_or_else(|| Error::UnknownAuthorizer { + account_id: reference.account_id, + authorizer: reference.authorizer.clone(), + }) + } + + pub async fn save(&self, account: &dyn Account) -> Result<()> { + let account_id = account.account_id(); + let reference = AccountRef { + account_id, + authorizer: account.authorizer_id().to_owned(), + }; + self.decoder(&reference)?; + let data = encode_account(account).map_err(|source| Error::AccountEncode { + account_id: reference.account_id, + authorizer: reference.authorizer.clone(), + source, + })?; + let path = self.account_path(&reference).await?; + tokio::fs::write(path, data).await?; + self.index + .set(|index| index.value.upsert(reference)) + .await?; + Ok(()) + } + + /// Runs an authorizer and persists the resulting account. + /// + /// Selecting the account is left to the caller so a failed sign-in never + /// changes the currently selected account. + pub async fn sign_in(&self, authorizer: &A) -> Result + where + A: Authorizer, + { + let account = authorizer.authorize().await?; + self.save(&account).await?; + Ok(account) + } + + pub async fn remove(&self, account_id: AccountId) -> Result<()> { + let reference = self.reference(account_id).await?; + let path = self.account_path(&reference).await?; + tokio::fs::remove_file(path).await?; + + self.index + .set(|index| index.value.remove(account_id)) + .await?; + Ok(()) + } + + pub async fn load(&self, reference: &AccountRef) -> Result> { + let decoder = self.decoder(reference)?; + let path = self.account_path(reference).await?; + let data = match tokio::fs::read_to_string(path).await { + Ok(data) => data, + Err(source) if source.kind() == ErrorKind::NotFound => { + return Err(Error::AccountNotFound { + account_id: reference.account_id, + }); + } + Err(source) => return Err(source.into()), + }; + let account = decoder(&data).map_err(|source| Error::AccountDecode { + account_id: reference.account_id, + authorizer: reference.authorizer.clone(), + source, + })?; + + if account.account_id() != reference.account_id + || account.authorizer_id() != reference.authorizer.as_str() + { + return Err(Error::AccountMismatch { + expected_id: reference.account_id, + expected_authorizer: reference.authorizer.clone(), + actual_id: account.account_id(), + actual_authorizer: account.authorizer_id().to_owned(), + }); + } + + Ok(account) + } + + pub async fn list(&self) -> Result>> { + let references = self.index.get(|index| index.value.accounts.clone()).await; + let mut accounts = Vec::with_capacity(references.len()); + for reference in references { + accounts.push(self.load(&reference).await?); + } + Ok(accounts) + } + + pub async fn selected(&self) -> Result>> { + let reference = self.index.get(|index| index.value.selected.clone()).await; + match reference { + Some(reference) => Ok(Some(self.load(&reference).await?)), + None => Ok(None), + } + } + + pub async fn refresh(&self, account_id: AccountId) -> Result> { + let account = self.load_by_id(account_id).await?; + self.persist_refresh(account).await + } + + pub async fn select(&self, account_id: AccountId) -> Result<()> { + let reference = self.reference(account_id).await?; + + self.index + .set(|index| index.value.selected = Some(reference)) + .await?; + Ok(()) + } + + /// Loads an account and refreshes it first when its credential is expiring. + /// + /// The refreshed account is persisted before it is returned. + pub async fn account(&self, account_id: AccountId) -> Result> { + let account = self.load_by_id(account_id).await?; + if account.needs_refresh() { + self.persist_refresh(account).await + } else { + Ok(account) + } + } + + async fn load_by_id(&self, account_id: AccountId) -> Result> { + let reference = self.reference(account_id).await?; + self.load(&reference).await + } + + async fn persist_refresh(&self, account: Box) -> Result> { + let refreshed = account.refresh().await?; + self.save(refreshed.as_ref()).await?; + Ok(refreshed) + } + + async fn reference(&self, account_id: AccountId) -> Result { + self.index + .get(|index| { + index + .value + .accounts + .iter() + .find(|stored| stored.account_id == account_id) + .cloned() + }) + .await + .ok_or(Error::AccountNotFound { account_id }) + } + + async fn account_path(&self, reference: &AccountRef) -> Result { + let id = format!("accounts/{}/{}", reference.authorizer, reference.account_id); + Ok(self + .scope + .get_full_path(&id, Some("json".to_owned())) + .await?) + } +} diff --git a/crates/launcher/src/authlib_injector.rs b/crates/launcher/src/authlib_injector.rs new file mode 100644 index 0000000..89ec2b3 --- /dev/null +++ b/crates/launcher/src/authlib_injector.rs @@ -0,0 +1,217 @@ +use std::{ + io::ErrorKind, + path::{Path, PathBuf}, + sync::Arc, +}; + +use elemental_infra::downloader::{DownloadTask, ElementalDownloader}; +use nidhogg::authlib_injector::{Artifact, Distribution}; +#[cfg(feature = "authlib-injector-embed")] +use nidhogg::authlib_injector::{ArtifactSource, EmbeddedArtifact, embedded}; +use sha2::{Digest, Sha256}; + +use crate::error::{Error, Result}; + +const ARTIFACT_DIRECTORY: &str = ".elemental/authlib-injector"; +const ARTIFACT_FILE_NAME: &str = "authlib-injector.jar"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthlibInjectorSource { + Remote, + #[cfg(feature = "authlib-injector-embed")] + Embedded, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthlibInjector { + path: PathBuf, + version: String, + source: AuthlibInjectorSource, + fallback_reason: Option, +} + +impl AuthlibInjector { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn source(&self) -> AuthlibInjectorSource { + self.source + } + + pub fn fallback_reason(&self) -> Option<&str> { + self.fallback_reason.as_deref() + } + + pub fn jvm_arguments(&self, auth_server: &str) -> [String; 2] { + [ + format!("-javaagent:{}={auth_server}", self.path.display()), + "-Dauthlibinjector.side=client".to_owned(), + ] + } +} + +pub(crate) async fn install( + storage_root: &Path, + downloader: &Arc, +) -> Result { + let path = std::path::absolute( + storage_root + .join(ARTIFACT_DIRECTORY) + .join(ARTIFACT_FILE_NAME), + )?; + + let distribution = Distribution::official()?; + #[cfg(feature = "authlib-injector-embed")] + { + match distribution.latest_or_embedded().await? { + ArtifactSource::Latest(artifact) => install_latest(&path, downloader, artifact).await, + ArtifactSource::Embedded(embedded) => install_embedded(&path, embedded).await, + } + } + #[cfg(not(feature = "authlib-injector-embed"))] + { + let artifact = distribution.latest().await?; + install_latest(&path, downloader, artifact).await + } +} + +async fn install_latest( + path: &Path, + downloader: &Arc, + artifact: Artifact, +) -> Result { + if existing_artifact_matches(path, &artifact.checksums.sha256).await? { + return Ok(AuthlibInjector { + path: path.to_owned(), + version: artifact.version, + source: AuthlibInjectorSource::Remote, + fallback_reason: None, + }); + } + + match install_remote(path, downloader, &artifact).await { + Ok(()) => Ok(AuthlibInjector { + path: path.to_owned(), + version: artifact.version, + source: AuthlibInjectorSource::Remote, + fallback_reason: None, + }), + Err(error) => install_embedded_fallback(path, error).await, + } +} + +async fn install_remote( + path: &Path, + downloader: &Arc, + artifact: &Artifact, +) -> Result<()> { + ensure_parent(path).await?; + remove_if_present(path).await?; + download_artifact(path, downloader, artifact).await?; + verify_file(path, &artifact.checksums.sha256).await +} + +async fn verify_file(path: &Path, expected: &str) -> Result<()> { + let Some(actual) = file_sha256(path).await? else { + return Err(Error::AuthlibInjectorMissing { + path: path.to_owned(), + }); + }; + if actual == expected { + return Ok(()); + } + + remove_if_present(path).await?; + Err(Error::AuthlibInjectorChecksum { + path: path.to_owned(), + expected: expected.to_owned(), + actual, + }) +} + +#[cfg(feature = "authlib-injector-embed")] +async fn install_embedded_fallback(path: &Path, error: Error) -> Result { + install_embedded_with_reason(path, embedded()?, Some(error.to_string())).await +} + +#[cfg(not(feature = "authlib-injector-embed"))] +async fn install_embedded_fallback(_path: &Path, error: Error) -> Result { + Err(error) +} + +async fn download_artifact( + path: &Path, + downloader: &Arc, + artifact: &Artifact, +) -> Result<()> { + let session = downloader + .session("install authlib-injector") + .open() + .await?; + session + .submit(DownloadTask::new(&artifact.download_url, path)) + .await?; + let report = session.finish().await?; + report.success()?; + Ok(()) +} + +#[cfg(feature = "authlib-injector-embed")] +async fn install_embedded(path: &Path, embedded: EmbeddedArtifact) -> Result { + install_embedded_with_reason(path, embedded, None).await +} + +#[cfg(feature = "authlib-injector-embed")] +async fn install_embedded_with_reason( + path: &Path, + embedded: EmbeddedArtifact, + fallback_reason: Option, +) -> Result { + ensure_parent(path).await?; + remove_if_present(path).await?; + tokio::fs::write(path, embedded.jar()).await?; + let artifact = embedded.artifact(); + verify_file(path, &artifact.checksums.sha256).await?; + + Ok(AuthlibInjector { + path: path.to_owned(), + version: artifact.version.clone(), + source: AuthlibInjectorSource::Embedded, + fallback_reason, + }) +} + +async fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + Ok(()) +} + +async fn existing_artifact_matches(path: &Path, expected: &str) -> Result { + let Some(actual) = file_sha256(path).await? else { + return Ok(false); + }; + Ok(actual == expected) +} + +async fn file_sha256(path: &Path) -> Result> { + match tokio::fs::read(path).await { + Ok(bytes) => Ok(Some(hex::encode(Sha256::digest(bytes)))), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(None), + Err(source) => Err(source.into()), + } +} + +async fn remove_if_present(path: &Path) -> Result<()> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(source) if source.kind() == ErrorKind::NotFound => Ok(()), + Err(source) => Err(source.into()), + } +} diff --git a/crates/launcher/src/error.rs b/crates/launcher/src/error.rs index 43bf3ea..0860d84 100644 --- a/crates/launcher/src/error.rs +++ b/crates/launcher/src/error.rs @@ -1,3 +1,5 @@ +use elemental_core::auth::accounts::AccountId; + #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { @@ -30,6 +32,66 @@ pub enum Error { #[error(transparent)] Storage(#[from] elemental_core::storage::Error), + + #[error(transparent)] + Shared(#[from] elemental_shared::error::Error), + + #[error(transparent)] + Auth(#[from] elemental_core::auth::Error), + + #[error("account '{account_id}' not found")] + AccountNotFound { account_id: AccountId }, + + #[error("account '{account_id}' uses unknown authorizer '{authorizer}'")] + UnknownAuthorizer { + account_id: AccountId, + authorizer: String, + }, + + #[error("encode account '{account_id}' for authorizer '{authorizer}' failed")] + AccountEncode { + account_id: AccountId, + authorizer: String, + #[source] + source: serde_json::Error, + }, + + #[error("decode account '{account_id}' for authorizer '{authorizer}' failed")] + AccountDecode { + account_id: AccountId, + authorizer: String, + #[source] + source: serde_json::Error, + }, + + #[error( + "account file does not match its index entry: expected '{expected_authorizer}/{expected_id}', found '{actual_authorizer}/{actual_id}'" + )] + AccountMismatch { + expected_id: AccountId, + expected_authorizer: String, + actual_id: AccountId, + actual_authorizer: String, + }, + + #[cfg(feature = "yggdrasil-auth")] + #[error(transparent)] + AuthlibInjectorDistribution(#[from] nidhogg::error::NidhoggError), + + #[cfg(feature = "yggdrasil-auth")] + #[error("authlib-injector download completed but '{}' is missing", path.display())] + AuthlibInjectorMissing { path: std::path::PathBuf }, + + #[cfg(feature = "yggdrasil-auth")] + #[error( + "authlib-injector checksum mismatch for '{}': expected {expected}, got {actual}", + path.display() + )] + AuthlibInjectorChecksum { + path: std::path::PathBuf, + expected: String, + actual: String, + }, } pub type Result = std::result::Result; diff --git a/crates/launcher/src/launcher.rs b/crates/launcher/src/launcher.rs index 9faf6e6..e2f6b22 100644 --- a/crates/launcher/src/launcher.rs +++ b/crates/launcher/src/launcher.rs @@ -4,7 +4,7 @@ use std::{ }; use elemental_core::{ - auth::credential::UserCredential, + auth::credential::Credential, launcher::{command::LaunchCommand, process}, runtime::{RuntimeResolver, distribution::Distribution}, storage::Storage, @@ -50,6 +50,9 @@ use crate::{ spec::DriverSpec, }; +#[cfg(feature = "yggdrasil-auth")] +use crate::authlib_injector::{self, AuthlibInjector}; + type LauncherGameStorage = Storage; type LauncherInstanceStorage = Storage>; @@ -210,7 +213,7 @@ where pub async fn build_launch_command( &self, prepared: &PreparedInstance, - credential: &UserCredential, + credential: &Credential, runtime_options: &RuntimeOptions, options: &LaunchOptions, ) -> Result { @@ -220,10 +223,40 @@ where &self.runtime_resolver, ) .await?; - let builder = launch_builder(credential, runtime, prepared, options); + #[cfg(feature = "yggdrasil-auth")] + let options = { + let mut options = options.clone(); + self.inject_authlib_injector(credential, &mut options) + .await?; + options + }; + #[cfg(not(feature = "yggdrasil-auth"))] + let options = options.clone(); + let builder = launch_builder(credential, runtime, prepared, &options); Ok(builder.build_command()?) } + #[cfg(feature = "yggdrasil-auth")] + pub async fn install_authlib_injector(&self) -> Result { + authlib_injector::install(&self.storage_root, &self.downloader).await + } + + #[cfg(feature = "yggdrasil-auth")] + async fn inject_authlib_injector( + &self, + credential: &Credential, + options: &mut LaunchOptions, + ) -> Result<()> { + let Some(auth_server) = &credential.auth_server else { + return Ok(()); + }; + let injector = self.install_authlib_injector().await?; + options + .extra_jvm_arguments + .extend(injector.jvm_arguments(auth_server)); + Ok(()) + } + pub fn launch(&self, command: LaunchCommand) -> Result { let runtime = command.runtime.clone(); let child = process::spawn_command(command)?; @@ -233,7 +266,7 @@ where pub async fn launch_prepared_instance( &self, prepared: &PreparedInstance, - credential: &UserCredential, + credential: &Credential, runtime_options: &RuntimeOptions, options: &LaunchOptions, ) -> Result { @@ -246,7 +279,7 @@ where pub async fn launch_instance( &self, instance_name: String, - credential: &UserCredential, + credential: &Credential, runtime_options: &RuntimeOptions, options: &LaunchOptions, ) -> Result { @@ -344,7 +377,7 @@ where } fn launch_builder( - credential: &UserCredential, + credential: &Credential, runtime: Distribution, prepared: &PreparedInstance, options: &LaunchOptions, diff --git a/crates/launcher/src/lib.rs b/crates/launcher/src/lib.rs index b6d3dfe..01c73bd 100644 --- a/crates/launcher/src/lib.rs +++ b/crates/launcher/src/lib.rs @@ -1,15 +1,24 @@ +pub mod account; +#[cfg(feature = "yggdrasil-auth")] +mod authlib_injector; mod builder; mod catalog; pub mod error; mod launcher; +#[cfg(feature = "offline-server")] +pub mod offline_server; mod origin; mod request; mod result; mod spec; +pub use account::{AccountIndex, AccountRef, AccountRegistry, AccountStore}; +#[cfg(feature = "yggdrasil-auth")] +pub use authlib_injector::{AuthlibInjector, AuthlibInjectorSource}; pub use builder::LauncherBuilder; pub use catalog::DriverCatalog; -pub use elemental_core::auth::credential::UserCredential; +pub use elemental_core::auth::credential::Credential; +pub use elemental_core::auth::texture::{Cape, ProfileTextures, Skin, SkinModel, TextureType}; pub use elemental_core::launcher::command::LaunchCommand; pub use elemental_infra::downloader::{DownloadProgress, DownloadSession}; pub use launcher::Launcher; diff --git a/crates/launcher/src/offline_server.rs b/crates/launcher/src/offline_server.rs new file mode 100644 index 0000000..76b67fa --- /dev/null +++ b/crates/launcher/src/offline_server.rs @@ -0,0 +1,3 @@ +pub use elemental_core::auth::server::{ + OfflineProfile, OfflineServer, OfflineServerBuilder, RunningOfflineServer, SkinModel, +}; diff --git a/crates/launcher/tests/account_store.rs b/crates/launcher/tests/account_store.rs new file mode 100644 index 0000000..cab81b7 --- /dev/null +++ b/crates/launcher/tests/account_store.rs @@ -0,0 +1,60 @@ +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use elemental_core::auth::{ + accounts::Account, authorizers::offline::OfflineAuthorizer, credential::CredentialKind, +}; +use elemental_launcher::AccountStore; +use elemental_shared::scope::Scope; + +fn temp_root() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is before the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "elemental-account-store-{}-{nonce}", + std::process::id() + )) +} + +#[tokio::test] +async fn account_store_persists_selects_and_removes_offline_account() { + let root = temp_root(); + let scope = Scope::Directory(root.clone()); + let store = AccountStore::open(scope.clone()).await.unwrap(); + + let account = store + .sign_in(&OfflineAuthorizer { + username: "TestPlayer".to_owned(), + }) + .await + .unwrap(); + let account_id = account.account_id(); + let expected_uuid = account.credential().await.unwrap().uuid; + + assert_eq!(account.display_name(), "TestPlayer"); + assert_eq!(store.list().await.unwrap().len(), 1); + assert!(store.selected().await.unwrap().is_none()); + + store.select(account_id).await.unwrap(); + + let reopened = AccountStore::open(scope).await.unwrap(); + let selected = reopened.selected().await.unwrap().unwrap(); + assert_eq!(selected.account_id(), account_id); + + let account = reopened.account(account_id).await.unwrap(); + let credential = account.credential().await.unwrap(); + assert_eq!(credential.username, "TestPlayer"); + assert_eq!(credential.uuid, expected_uuid); + assert_eq!(credential.kind, CredentialKind::Legacy); + assert!(credential.access_token.is_empty()); + + reopened.remove(account_id).await.unwrap(); + assert!(reopened.list().await.unwrap().is_empty()); + assert!(reopened.selected().await.unwrap().is_none()); + + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/launchpad/src/commands.rs b/crates/launchpad/src/commands.rs index 7d9847b..f67f15b 100644 --- a/crates/launchpad/src/commands.rs +++ b/crates/launchpad/src/commands.rs @@ -6,7 +6,9 @@ use std::{ use anyhow::{Context, Result}; use elemental::{ core::{ - auth::{authorizer::Authorizer, authorizers::offline::OfflineAuthorizer}, + auth::{ + accounts::Account, authorizers::Authorizer, authorizers::offline::OfflineAuthorizer, + }, launcher::command::LaunchCommand, runtime::provider::{RuntimeProvider, default_providers}, runtime::providers::custom::new_custom_provider, @@ -34,11 +36,12 @@ async fn run_launch(config: LaunchpadConfig) -> Result<()> { let (prepared, prepare_elapsed) = prepare_instance(&launcher, &config).await?; - let credential = OfflineAuthorizer { + let account = OfflineAuthorizer { username: "Player".to_owned(), } .authorize() .await?; + let credential = account.credential().await?; let command = launcher .build_launch_command(&prepared, &credential, &config.runtime, &config.options) .await?; diff --git a/docs/launch-flow.md b/docs/launch-flow.md index 152dd1c..328c47f 100644 --- a/docs/launch-flow.md +++ b/docs/launch-flow.md @@ -792,7 +792,7 @@ as `-Djava.library.path`: fn apply_default_variables( &mut self, metadata: &PistonMetaData, - credential: &UserCredential, + credential: &Credential, version_name: String, paths: &LaunchPaths, ) { @@ -808,11 +808,12 @@ fn apply_default_variables( self.inner.assets_index_name = metadata.assets.clone(); self.inner.auth_uuid = credential.uuid.clone(); self.inner.auth_access_token = credential.access_token.clone(); - // Offline credentials have no access token and therefore use legacy user type. - self.inner.user_type = if self.inner.auth_access_token.is_empty() { - UserType::Legacy - } else { - UserType::Msa + // The credential kind selects the user type explicitly instead of inferring + // it from an empty access token. + self.inner.user_type = match credential.kind { + CredentialKind::Legacy => UserType::Legacy, + CredentialKind::Mojang => UserType::Mojang, + CredentialKind::Msa => UserType::Msa, }; self.inner.version_type = metadata.release_type.clone(); self.inner.library_directory = paths.libraries_root.to_string_lossy().to_string();