From 6dfac238d3bc4ba8d3c679c1da7931c80ac97638 Mon Sep 17 00:00:00 2001 From: foxyolk <117400506+FoxYolk@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:36:27 -0600 Subject: [PATCH] Add MS Java access-token & cookies auth services Add support for importing Microsoft Java accounts via raw Minecraft access tokens and browser cookies. Introduces MSJavaAccessTokenAuthService (access-token-only auth, non-refreshable) and MSJavaCookiesAuthService (exchanges login.live.com cookies for a refresh token), and extends MSJavaRefreshTokenAuthService with cookie-import helpers/validation. Update MCAuthService to include new service variants and conversion logic, and extend AuthType/proto enums to expose MICROSOFT_JAVA_ACCESS_TOKEN and MICROSOFT_JAVA_COOKIES. OnlineChainJavaData gains helpers to detect/access access-token-only accounts. InstanceManager, SFSessionService and BotConnection are adjusted to handle non-refreshable access-token accounts and to skip expired auth. Also: javadoc Gradle task fixes (classpath + delombok ordering and configuration cache note), .gitignore additions for local artifacts, add start.bat helper, and include a bundled JavaAuthManager.class binary. Protobuf messages updated for new credential/service types. --- .gitignore | 24 + builder.txt | Bin 0 -> 11654 bytes javadoc/build.gradle.kts | 21 +- .../soulfiremc/server/InstanceManager.java | 49 +- .../soulfiremc/server/account/AuthType.java | 1 + .../server/account/MCAuthService.java | 6 +- .../account/MSJavaAccessTokenAuthService.java | 153 ++++++ .../account/MSJavaCookiesAuthService.java | 457 ++++++++++++++++++ .../MSJavaRefreshTokenAuthService.java | 386 ++++++++++++++- .../account/service/OnlineChainJavaData.java | 21 + .../soulfiremc/server/bot/BotConnection.java | 2 +- .../server/bot/SFSessionService.java | 9 +- .../server/grpc/MCAuthServiceImpl.java | 6 + .../minecraftauth/java/JavaAuthManager.class | Bin 0 -> 18802 bytes proto/src/main/proto/soulfire/common.proto | 13 + proto/src/main/proto/soulfire/mc-auth.proto | 1 + start.bat | 39 ++ untracked.txt | Bin 0 -> 30838 bytes untracked8.txt | 313 ++++++++++++ 19 files changed, 1473 insertions(+), 28 deletions(-) create mode 100644 builder.txt create mode 100644 mod/src/main/java/com/soulfiremc/server/account/MSJavaAccessTokenAuthService.java create mode 100644 mod/src/main/java/com/soulfiremc/server/account/MSJavaCookiesAuthService.java create mode 100644 net/raphimc/minecraftauth/java/JavaAuthManager.class create mode 100644 start.bat create mode 100644 untracked.txt create mode 100644 untracked8.txt diff --git a/.gitignore b/.gitignore index 386277044..31a11fb9e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,27 @@ out/ temp/ tmp/ + +# Downloaded libraries +/libraries/ + +# SQLite database +*.sqlite +*.sqlite-journal + +# Secrets +secret-key.bin + +# Minecraft runtime data +/minecraft/.fabric/ +/minecraft/config/viafabricplus/ +/minecraft/data/ +/minecraft/downloads/ +/minecraft/mods/ + +# Spark profiling tmp +/config/spark/ + +# MC Downloads +/mc-downloads/ +/logs diff --git a/builder.txt b/builder.txt new file mode 100644 index 0000000000000000000000000000000000000000..529baf6369d790fbc552f2cf19cbcde9bc4b28d5 GIT binary patch literal 11654 zcmeHNZBNud5T4H_{)dL}0TR<_qMtM-co+dCkN^o^&svTH?j`gQ@XPD)NzG9vsMVKeTuRbNX0=yIB{Sfc;L3Iv_5ynG&57k(tM$SOf zzvm=#haM>c`l+}F;4{TMLnZMH92pDG--je31LGXy8q!O8O>gKGf_#0Yxe2TlLy{0y;b&Wrcd8_da5n=! zM#J_UD?JP#tA5*0SxtUp{SaK)F9wk12%Omld>1GQZ*dnv(qp=Vd@xq$EzW5S@!_(eWC2Zo}VbrH?Zu4E|qi%q?v&G5T#xb-{k@zv7734EI) zO>8Xq^JA-p{TNX_L(buhowvSgbhYrGs$837(3@;G`DXGS656D+* zGQP@MI7B9xs2Yl+LazW>P1!W7tqrdB?o@v4sc){CxYE{jXN>qM(vknowC2*?1lK>h zs*SJ`HCao==l3vEaaSW9&>oO*^}O-GuiM_?1(4{@BSf`4!8K5Mm+v%Z?tS#Hbmg^a zRagImmW;it=wEcl-BFARM|W!6*L`ZC%_f!#;I)$n8;T#*>G-Pp?IL)^I(3_hA$DC4=p*);c+YqWTD^~*?hD$l@O7g)i>qv>Z1FMXGm0Buo^J86xiXm>or`ku z@#S-97M_45ne=3@k+VC;Xj@s|tmRo)u|2Q^X*)R1!;lIhw BV$T2o literal 0 HcmV?d00001 diff --git a/javadoc/build.gradle.kts b/javadoc/build.gradle.kts index 856e3d5d4..d3f7ab050 100644 --- a/javadoc/build.gradle.kts +++ b/javadoc/build.gradle.kts @@ -28,14 +28,7 @@ dependencies { } } -// Configure mod compile classpath - mod project is already evaluated due to evaluationDependsOn -val javadocTask = tasks.named("javadoc") -val modCompileClasspath = project(":mod").extensions - .getByType()["main"].compileClasspath - -javadocTask.configure { - classpath = classpath.plus(modCompileClasspath) -} +val modCompileClasspath = project(":mod").configurations.named("compileClasspath") val usedJavadocTool: Provider = javaToolchains.javadocToolFor { languageVersion = JavaLanguageVersion.of(25) @@ -51,6 +44,18 @@ tasks { opts.addBooleanOption("-enable-preview", true) opts.source = "25" + rootProject.subprojects.forEach { subproject -> + if (subproject.name != "javadoc" && subproject.name != "data-generator" && subproject.plugins.hasPlugin("java")) { + val delombokTask = subproject.tasks.findByName("delombok") + if (delombokTask != null) { + dependsOn(delombokTask) + } + } + } + + notCompatibleWithConfigurationCache("Uses cross-project classpaths") + + classpath = classpath.plus(modCompileClasspath.get()) javadocTool = usedJavadocTool } diff --git a/mod/src/main/java/com/soulfiremc/server/InstanceManager.java b/mod/src/main/java/com/soulfiremc/server/InstanceManager.java index a67448898..0cf5ce50d 100644 --- a/mod/src/main/java/com/soulfiremc/server/InstanceManager.java +++ b/mod/src/main/java/com/soulfiremc/server/InstanceManager.java @@ -249,13 +249,22 @@ private void refreshExpiredAccounts() { log.info("Refreshing expired accounts"); } - accounts.add(authService.refresh( - account, - settingsSource.get(AccountSettings.USE_PROXIES_FOR_ACCOUNT_AUTH) - ? SFHelpers.getRandomEntry(settingsSource.proxies()) : null, - scheduler - ).join()); - refreshed++; + try { + accounts.add(authService.refresh( + account, + settingsSource.get(AccountSettings.USE_PROXIES_FOR_ACCOUNT_AUTH) + ? SFHelpers.getRandomEntry(settingsSource.proxies()) : null, + scheduler + ).join()); + refreshed++; + } catch (CompletionException e) { + if (e.getCause() instanceof UnsupportedOperationException) { + log.warn("Account {} cannot be refreshed: {}", account.lastKnownName(), e.getCause().getMessage()); + accounts.add(account); + } else { + throw e; + } + } } else { accounts.add(account); } @@ -288,12 +297,21 @@ private MinecraftAccount refreshAccount(MinecraftAccount account) { } log.info("Account {} is expired, refreshing before connecting", account.lastKnownName()); - var refreshedAccount = authService.refresh( - account, - settingsSource.get(AccountSettings.USE_PROXIES_FOR_ACCOUNT_AUTH) - ? SFHelpers.getRandomEntry(settingsSource.proxies()) : null, - scheduler - ).join(); + MinecraftAccount refreshedAccount; + try { + refreshedAccount = authService.refresh( + account, + settingsSource.get(AccountSettings.USE_PROXIES_FOR_ACCOUNT_AUTH) + ? SFHelpers.getRandomEntry(settingsSource.proxies()) : null, + scheduler + ).join(); + } catch (CompletionException e) { + if (e.getCause() instanceof UnsupportedOperationException) { + log.warn("Account {} cannot be refreshed: {}", account.lastKnownName(), e.getCause().getMessage()); + return account; + } + throw e; + } var accounts = new ArrayList<>(settingsSource.accounts().values()); accounts.replaceAll(a -> a.authType().equals(refreshedAccount.authType()) && a.profileId().equals(refreshedAccount.profileId()) ? refreshedAccount : a); @@ -427,6 +445,11 @@ private void start() { var factories = new ArrayBlockingQueue(botAmount); while (!accountQueue.isEmpty()) { var minecraftAccount = refreshAccount(accountQueue.poll()); + var postRefreshAuthService = MCAuthService.convertService(minecraftAccount.authType()); + if (postRefreshAuthService.isExpired(minecraftAccount)) { + log.warn("Skipping account {} because its authentication is expired and cannot be refreshed", minecraftAccount.lastKnownName()); + continue; + } var lastAccountObject = new AtomicReference<>(minecraftAccount); var proxyData = getProxy(proxies).orElse(null); diff --git a/mod/src/main/java/com/soulfiremc/server/account/AuthType.java b/mod/src/main/java/com/soulfiremc/server/account/AuthType.java index bf84f55de..9406f09bc 100644 --- a/mod/src/main/java/com/soulfiremc/server/account/AuthType.java +++ b/mod/src/main/java/com/soulfiremc/server/account/AuthType.java @@ -32,6 +32,7 @@ public enum AuthType { MICROSOFT_JAVA_DEVICE_CODE("Microsoft Java Device Code", OnlineChainJavaData.class), MICROSOFT_BEDROCK_DEVICE_CODE("Microsoft Bedrock Device Code", BedrockData.class), MICROSOFT_JAVA_REFRESH_TOKEN("Microsoft Java Refresh Token", OnlineChainJavaData.class), + MICROSOFT_JAVA_ACCESS_TOKEN("Microsoft Java Access Token", OnlineChainJavaData.class), OFFLINE("Offline", OfflineJavaData.class); private final String displayName; diff --git a/mod/src/main/java/com/soulfiremc/server/account/MCAuthService.java b/mod/src/main/java/com/soulfiremc/server/account/MCAuthService.java index a34057f77..4d36b6780 100644 --- a/mod/src/main/java/com/soulfiremc/server/account/MCAuthService.java +++ b/mod/src/main/java/com/soulfiremc/server/account/MCAuthService.java @@ -29,13 +29,15 @@ import java.util.function.Consumer; public sealed interface MCAuthService - permits MSBedrockCredentialsAuthService, MSBedrockDeviceCodeAuthService, MSJavaCredentialsAuthService, MSJavaDeviceCodeAuthService, MSJavaRefreshTokenAuthService, OfflineAuthService { + permits MSBedrockCredentialsAuthService, MSBedrockDeviceCodeAuthService, MSJavaAccessTokenAuthService, MSJavaCookiesAuthService, MSJavaCredentialsAuthService, MSJavaDeviceCodeAuthService, MSJavaRefreshTokenAuthService, OfflineAuthService { static MCAuthService convertService(AccountTypeCredentials service) { return switch (service) { case MICROSOFT_JAVA_CREDENTIALS -> MSJavaCredentialsAuthService.INSTANCE; case MICROSOFT_BEDROCK_CREDENTIALS -> MSBedrockCredentialsAuthService.INSTANCE; case OFFLINE -> OfflineAuthService.INSTANCE; case MICROSOFT_JAVA_REFRESH_TOKEN -> MSJavaRefreshTokenAuthService.INSTANCE; + case MICROSOFT_JAVA_ACCESS_TOKEN -> MSJavaAccessTokenAuthService.INSTANCE; + case MICROSOFT_JAVA_COOKIES -> MSJavaCookiesAuthService.INSTANCE; case UNRECOGNIZED -> throw new IllegalArgumentException("Unrecognized service"); }; } @@ -56,6 +58,7 @@ public sealed interface MCAuthService case MICROSOFT_JAVA_DEVICE_CODE -> MSJavaDeviceCodeAuthService.INSTANCE; case MICROSOFT_BEDROCK_DEVICE_CODE -> MSBedrockDeviceCodeAuthService.INSTANCE; case MICROSOFT_JAVA_REFRESH_TOKEN -> MSJavaRefreshTokenAuthService.INSTANCE; + case MICROSOFT_JAVA_ACCESS_TOKEN -> MSJavaAccessTokenAuthService.INSTANCE; case UNRECOGNIZED -> throw new IllegalArgumentException("Unrecognized service"); }; } @@ -68,6 +71,7 @@ public sealed interface MCAuthService case MICROSOFT_BEDROCK_DEVICE_CODE -> MSBedrockDeviceCodeAuthService.INSTANCE; case OFFLINE -> OfflineAuthService.INSTANCE; case MICROSOFT_JAVA_REFRESH_TOKEN -> MSJavaRefreshTokenAuthService.INSTANCE; + case MICROSOFT_JAVA_ACCESS_TOKEN -> MSJavaAccessTokenAuthService.INSTANCE; }; } diff --git a/mod/src/main/java/com/soulfiremc/server/account/MSJavaAccessTokenAuthService.java b/mod/src/main/java/com/soulfiremc/server/account/MSJavaAccessTokenAuthService.java new file mode 100644 index 000000000..271caabb2 --- /dev/null +++ b/mod/src/main/java/com/soulfiremc/server/account/MSJavaAccessTokenAuthService.java @@ -0,0 +1,153 @@ +/* + * SoulFire + * Copyright (C) 2026 AlexProgrammerDE + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.soulfiremc.server.account; + +import com.google.gson.JsonObject; +import com.soulfiremc.server.account.service.OnlineChainJavaData; +import com.soulfiremc.server.proxy.SFProxy; +import com.soulfiremc.server.util.structs.GsonInstance; +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.stream.Collectors; + +public final class MSJavaAccessTokenAuthService + implements MCAuthService { + public static final MSJavaAccessTokenAuthService INSTANCE = new MSJavaAccessTokenAuthService(); + private static final String PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"; + private static final long EXPIRY_SKEW_SECONDS = 30; + + private MSJavaAccessTokenAuthService() {} + + @Override + public CompletableFuture login(MSJavaAccessTokenAuthData data, @Nullable SFProxy proxyData, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + try { + var profileJson = fetchProfile(data.accessToken); + + var id = profileJson.get("id").getAsString(); + // Convert from undashed to dashed UUID format + var profileId = UUID.fromString( + id.replaceFirst("(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", + "$1-$2-$3-$4-$5")); + var name = profileJson.get("name").getAsString(); + + // We don't have a full auth chain, so we store minimal data. + // The access token is stored so it can be used for server joining. + var authChain = new JsonObject(); + authChain.addProperty("_saveVersion", 1); + authChain.addProperty("_accessToken", data.accessToken); + authChain.addProperty("_profileId", profileId.toString()); + authChain.addProperty("_profileName", name); + authChain.addProperty("_accessTokenOnly", true); + + return new MinecraftAccount( + AuthType.MICROSOFT_JAVA_ACCESS_TOKEN, + profileId, + name, + new OnlineChainJavaData(authChain), + Map.of(), + Map.of()); + } catch (Exception e) { + throw new CompletionException(e); + } + }, executor); + } + + private JsonObject fetchProfile(String accessToken) throws Exception { + var url = URI.create(PROFILE_URL).toURL(); + var connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Authorization", "Bearer " + accessToken); + connection.setRequestProperty("Accept", "application/json"); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(10_000); + + var statusCode = connection.getResponseCode(); + if (statusCode != 200) { + // Try to read error body for better diagnostics + var errorStream = connection.getErrorStream(); + var errorBody = ""; + if (errorStream != null) { + try (var reader = new BufferedReader(new InputStreamReader(errorStream, StandardCharsets.UTF_8))) { + errorBody = reader.lines().collect(Collectors.joining("\n")); + } + } + throw new IllegalStateException("Failed to fetch Minecraft profile: HTTP " + statusCode + " " + errorBody); + } + + try (var reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + var body = reader.lines().collect(Collectors.joining("\n")); + return GsonInstance.GSON.fromJson(body, JsonObject.class); + } finally { + connection.disconnect(); + } + } + + @Override + public MSJavaAccessTokenAuthData createData(String data) { + return new MSJavaAccessTokenAuthData(data.strip()); + } + + @Override + public CompletableFuture refresh(MinecraftAccount account, @Nullable SFProxy proxyData, Executor executor) { + return CompletableFuture.failedFuture( + new UnsupportedOperationException("Access token accounts cannot be refreshed. Please re-import the account with a new access token.")); + } + + @Override + public boolean isExpired(MinecraftAccount account) { + var accessToken = ((OnlineChainJavaData) account.accountData()).getAccessToken(null); + var expSeconds = getJwtExpSeconds(accessToken); + if (expSeconds == null) { + return false; + } + var nowSeconds = System.currentTimeMillis() / 1000; + return nowSeconds >= (expSeconds - EXPIRY_SKEW_SECONDS); + } + + private static @Nullable Long getJwtExpSeconds(String accessToken) { + var parts = accessToken.split("\\."); + if (parts.length < 2) { + return null; + } + + try { + var payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + var payload = GsonInstance.GSON.fromJson(payloadJson, JsonObject.class); + if (payload == null || !payload.has("exp")) { + return null; + } + return payload.get("exp").getAsLong(); + } catch (Exception _) { + return null; + } + } + + public record MSJavaAccessTokenAuthData(String accessToken) {} +} diff --git a/mod/src/main/java/com/soulfiremc/server/account/MSJavaCookiesAuthService.java b/mod/src/main/java/com/soulfiremc/server/account/MSJavaCookiesAuthService.java new file mode 100644 index 000000000..65cea5dbe --- /dev/null +++ b/mod/src/main/java/com/soulfiremc/server/account/MSJavaCookiesAuthService.java @@ -0,0 +1,457 @@ +/* + * SoulFire + * Copyright (C) 2026 AlexProgrammerDE + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.soulfiremc.server.account; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.soulfiremc.builddata.BuildData; +import com.soulfiremc.server.proxy.SFProxy; +import com.soulfiremc.server.util.LenniHttpHelper; +import com.soulfiremc.server.util.ReactorHttpHelper; +import com.soulfiremc.server.util.structs.GsonInstance; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpStatusClass; +import net.raphimc.minecraftauth.java.JavaAuthManager; +import org.checkerframework.checker.nullness.qual.Nullable; +import reactor.core.publisher.Mono; +import reactor.netty.ByteBufFlux; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; + +public final class MSJavaCookiesAuthService + implements MCAuthService { + public static final MSJavaCookiesAuthService INSTANCE = new MSJavaCookiesAuthService(); + private static final String CLIENT_ID = "00000000402b5328"; + private static final String REDIRECT_URI = "https://login.live.com/oauth20_desktop.srf"; + private static final String SCOPE = "service::user.auth.xboxlive.com::MBI_SSL"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private MSJavaCookiesAuthService() {} + + @Override + public CompletableFuture login(MSJavaCookiesAuthData data, @Nullable SFProxy proxyData, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + try { + var refreshToken = exchangeCookieInputForRefreshToken(data.cookieInput, proxyData); + var authManager = JavaAuthManager.create(LenniHttpHelper.client(proxyData)) + .login(refreshToken); + return AuthHelpers.fromJavaAuthManager(AuthType.MICROSOFT_JAVA_REFRESH_TOKEN, authManager, null); + } catch (Exception e) { + throw new CompletionException(e); + } + }, executor); + } + + @Override + public MSJavaCookiesAuthData createData(String data) { + var t = data.strip(); + if (t.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: empty input"); + } + return new MSJavaCookiesAuthData(t); + } + + @Override + public CompletableFuture refresh(MinecraftAccount account, @Nullable SFProxy proxyData, Executor executor) { + return MSJavaRefreshTokenAuthService.INSTANCE.refresh(account, proxyData, executor); + } + + @Override + public boolean isExpired(MinecraftAccount account) { + return MSJavaRefreshTokenAuthService.INSTANCE.isExpired(account); + } + + private static String exchangeCookieInputForRefreshToken(String cookieInput, @Nullable SFProxy proxyData) { + var cookieHeader = cookieInputToCookieHeader(cookieInput); + + var verifier = generatePkceVerifier(); + var challenge = pkceS256Challenge(verifier); + + var authorizeUri = URI.create( + "https://login.live.com/oauth20_authorize.srf" + + "?client_id=" + urlEncode(CLIENT_ID) + + "&response_type=code" + + "&response_mode=query" + + "&redirect_uri=" + urlEncode(REDIRECT_URI) + + "&scope=" + urlEncode(SCOPE) + + "&prompt=none" + + "&code_challenge=" + urlEncode(challenge) + + "&code_challenge_method=S256" + ); + + var location = ReactorHttpHelper.createReactorClient(proxyData, false) + .responseTimeout(Duration.ofSeconds(20)) + .followRedirect(false) + .headers(h -> { + h.set(HttpHeaderNames.USER_AGENT, "SoulFire/" + BuildData.VERSION); + h.set(HttpHeaderNames.COOKIE, cookieHeader); + }) + .get() + .uri(authorizeUri) + .responseSingle((res, content) -> { + if (res.status().codeClass() != HttpStatusClass.REDIRECTION) { + return content.asString(StandardCharsets.UTF_8) + .defaultIfEmpty("") + .flatMap(_ -> Mono.error( + new IllegalStateException("Cookie import failed: unexpected authorize response " + res.status().code()) + )); + } + return Mono.justOrEmpty(res.responseHeaders().get(HttpHeaderNames.LOCATION)); + }) + .block(); + + if (location == null || location.isEmpty()) { + throw new IllegalStateException("Cookie import failed: missing authorize redirect"); + } + + var redirect = URI.create(location); + var expectedRedirect = URI.create(REDIRECT_URI); + if (redirect.getHost() == null + || !expectedRedirect.getHost().equalsIgnoreCase(redirect.getHost()) + || !expectedRedirect.getPath().equals(redirect.getPath())) { + throw new IllegalStateException("Cookie import failed: unexpected redirect to " + redirect.getHost() + redirect.getPath()); + } + var params = parseQueryParams(redirect.getRawQuery()); + var error = params.get("error"); + if (error != null && !error.isEmpty()) { + var description = params.get("error_description"); + throw new IllegalStateException("Cookie import failed: authorize error " + error + textSuffix(description)); + } + + var code = params.get("code"); + if (code == null || code.isEmpty()) { + throw new IllegalStateException("Cookie import failed: missing authorization code"); + } + + var tokenBody = + "client_id=" + urlEncode(CLIENT_ID) + + "&redirect_uri=" + urlEncode(REDIRECT_URI) + + "&scope=" + urlEncode(SCOPE) + + "&grant_type=authorization_code" + + "&code=" + urlEncode(code) + + "&code_verifier=" + urlEncode(verifier); + + var tokenJson = ReactorHttpHelper.createReactorClient(proxyData, false) + .responseTimeout(Duration.ofSeconds(20)) + .headers(h -> { + h.set(HttpHeaderNames.USER_AGENT, "SoulFire/" + BuildData.VERSION); + h.set(HttpHeaderNames.ACCEPT, "application/json"); + h.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded"); + }) + .post() + .uri(URI.create("https://login.live.com/oauth20_token.srf")) + .send(ByteBufFlux.fromString(Mono.just(tokenBody))) + .responseSingle((res, content) -> { + if (res.status().codeClass() != HttpStatusClass.SUCCESS) { + return content.asString(StandardCharsets.UTF_8) + .defaultIfEmpty("") + .flatMap(body -> Mono.error( + new IllegalStateException("Cookie import failed: token exchange failed " + res.status().code() + oauthErrorSuffix(body)) + )); + } + return content.asString(StandardCharsets.UTF_8); + }) + .block(); + + if (tokenJson == null || tokenJson.isEmpty()) { + throw new IllegalStateException("Cookie import failed: empty token response"); + } + + JsonObject obj; + try { + obj = GsonInstance.GSON.fromJson(tokenJson, JsonObject.class); + } catch (Exception e) { + throw new IllegalStateException("Cookie import failed: invalid token JSON"); + } + + if (obj == null || !obj.has("refresh_token")) { + throw new IllegalStateException("Cookie import failed: missing refresh token"); + } + + var refreshToken = obj.get("refresh_token").getAsString(); + if (refreshToken == null || refreshToken.isEmpty()) { + throw new IllegalStateException("Cookie import failed: empty refresh token"); + } + + return refreshToken; + } + + private static String generatePkceVerifier() { + var bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String pkceS256Challenge(String verifier) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (Exception e) { + throw new IllegalStateException("Cookie import failed: PKCE unavailable"); + } + } + + private static String oauthErrorSuffix(@Nullable String body) { + if (body == null || body.isEmpty()) return ""; + var t = body.strip(); + if (t.isEmpty()) return ""; + + if (t.length() > 2000) { + t = t.substring(0, 2000); + } + + try { + var obj = GsonInstance.GSON.fromJson(t, JsonObject.class); + if (obj != null && obj.has("error")) { + var err = obj.get("error").getAsString(); + var desc = obj.has("error_description") ? obj.get("error_description").getAsString() : null; + if (desc != null && desc.length() > 200) { + desc = desc.substring(0, 200); + } + if (desc != null && !desc.isEmpty()) { + return " (error: " + err + ", description: " + desc + ")"; + } + if (err != null && !err.isEmpty()) { + return " (error: " + err + ")"; + } + } + } catch (Exception ignored) { + } + + return ""; + } + + private static String textSuffix(@Nullable String text) { + if (text == null || text.isEmpty()) return ""; + var t = text.strip(); + if (t.isEmpty()) return ""; + if (t.length() > 200) { + t = t.substring(0, 200); + } + return " (description: " + t + ")"; + } + + private static String cookieInputToCookieHeader(String cookieInput) { + var t = stripEnclosingQuotes(cookieInput.strip()); + if (t.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: empty input"); + } + + if (looksLikeCookieJar(t)) { + var cookieHeader = cookieJarToCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no valid login.live.com cookies found"); + } + return cookieHeader; + } + + if (looksLikeCookieEditorJson(t)) { + var cookieHeader = cookieEditorJsonToCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no valid login.live.com cookies found"); + } + return cookieHeader; + } + + if (looksLikeCookieHeader(t)) { + var cookieHeader = normalizeCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no usable cookies found"); + } + return cookieHeader; + } + + throw new IllegalArgumentException("Cookie import failed: unrecognized cookie format"); + } + + private static boolean looksLikeCookieJar(String input) { + return input.contains("\t") + && (input.contains("__Host-MSAAUTHP") || input.contains("login.live.com")); + } + + private static boolean looksLikeCookieHeader(String input) { + return input.contains("__Host-MSAAUTHP=") + || input.contains("MSPRequ=") + || input.contains("MSPOK=") + || input.contains("PPLState="); + } + + private static boolean looksLikeCookieEditorJson(String input) { + var t = input.strip(); + if (!(t.startsWith("[") && t.endsWith("]"))) return false; + return t.contains("\"domain\"") && t.contains("\"name\"") && t.contains("\"value\""); + } + + private static String cookieJarToCookieHeader(String cookieJar) { + var out = new StringBuilder(); + for (var line : cookieJar.split("\\R")) { + var l = line.strip(); + if (l.isEmpty() || l.startsWith("#")) { + continue; + } + + var parts = l.split("\t"); + if (parts.length < 7) { + continue; + } + + var domain = stripDomain(parts[0].strip()); + if (!(domain.endsWith("login.live.com") || domain.endsWith(".login.live.com"))) { + continue; + } + + var name = parts[5].strip(); + var value = parts[6].strip(); + if (name.isEmpty() || value.isEmpty()) { + continue; + } + + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name).append('=').append(value); + } + + return out.toString(); + } + + private static String cookieEditorJsonToCookieHeader(String cookieJson) { + JsonArray arr; + try { + arr = GsonInstance.GSON.fromJson(cookieJson, JsonArray.class); + } catch (Exception e) { + return ""; + } + if (arr == null || arr.isEmpty()) return ""; + + var out = new StringBuilder(); + for (var el : arr) { + if (!el.isJsonObject()) continue; + var obj = el.getAsJsonObject(); + if (!obj.has("name") || !obj.has("value")) continue; + var name = obj.get("name").getAsString(); + var value = obj.get("value").getAsString(); + if (name == null || name.isEmpty() || value == null || value.isEmpty()) continue; + + String domain = null; + if (obj.has("domain")) { + try { + domain = obj.get("domain").getAsString(); + } catch (Exception ignored) { + domain = null; + } + } + if (domain != null && !domain.isEmpty()) { + domain = stripDomain(domain); + if (!(domain.endsWith("login.live.com") || domain.endsWith(".login.live.com"))) { + continue; + } + } + + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name.strip()).append('=').append(value.strip()); + } + + return out.toString(); + } + + private static String normalizeCookieHeader(String cookieHeaderInput) { + var t = stripEnclosingQuotes(cookieHeaderInput.strip()); + t = t.replace("\r\n", "\n"); + t = t.replace("\n", "; "); + t = t.replaceFirst("^(?i)cookie:\\s*", ""); + + var out = new StringBuilder(); + for (var piece : t.split(";")) { + var p = piece.strip(); + if (p.isEmpty()) continue; + var idx = p.indexOf('='); + if (idx <= 0) continue; + var name = p.substring(0, idx).strip(); + var value = p.substring(idx + 1).strip(); + if (name.isEmpty() || value.isEmpty()) continue; + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name).append('=').append(value); + } + return out.toString(); + } + + private static String stripEnclosingQuotes(String v) { + var t = v.strip(); + if (t.length() >= 2) { + var first = t.charAt(0); + var last = t.charAt(t.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return t.substring(1, t.length() - 1).strip(); + } + } + return t; + } + + private static String stripDomain(String domain) { + var d = domain.strip(); + if (d.startsWith("http://")) d = d.substring("http://".length()); + if (d.startsWith("https://")) d = d.substring("https://".length()); + var slash = d.indexOf('/'); + if (slash >= 0) d = d.substring(0, slash); + return d.strip(); + } + + private static Map parseQueryParams(@Nullable String rawQuery) { + var out = new HashMap(); + if (rawQuery == null || rawQuery.isEmpty()) { + return out; + } + + for (var pair : rawQuery.split("&")) { + if (pair.isEmpty()) continue; + var idx = pair.indexOf('='); + var key = idx >= 0 ? pair.substring(0, idx) : pair; + var value = idx >= 0 ? pair.substring(idx + 1) : ""; + out.put(urlDecode(key), urlDecode(value)); + } + return out; + } + + private static String urlEncode(String v) { + return URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + private static String urlDecode(String v) { + return URLDecoder.decode(v, StandardCharsets.UTF_8); + } + + public record MSJavaCookiesAuthData(String cookieInput) {} +} diff --git a/mod/src/main/java/com/soulfiremc/server/account/MSJavaRefreshTokenAuthService.java b/mod/src/main/java/com/soulfiremc/server/account/MSJavaRefreshTokenAuthService.java index 9a6833e7d..af3c074a5 100644 --- a/mod/src/main/java/com/soulfiremc/server/account/MSJavaRefreshTokenAuthService.java +++ b/mod/src/main/java/com/soulfiremc/server/account/MSJavaRefreshTokenAuthService.java @@ -17,13 +17,28 @@ */ package com.soulfiremc.server.account; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.soulfiremc.builddata.BuildData; import com.soulfiremc.server.account.service.OnlineChainJavaData; import com.soulfiremc.server.proxy.SFProxy; -import com.soulfiremc.server.settings.lib.BotSettingsImpl; import com.soulfiremc.server.util.LenniHttpHelper; +import com.soulfiremc.server.util.ReactorHttpHelper; +import com.soulfiremc.server.util.structs.GsonInstance; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpStatusClass; import net.raphimc.minecraftauth.java.JavaAuthManager; import org.checkerframework.checker.nullness.qual.Nullable; +import reactor.core.publisher.Mono; +import reactor.netty.ByteBufFlux; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; @@ -31,6 +46,9 @@ public final class MSJavaRefreshTokenAuthService implements MCAuthService { public static final MSJavaRefreshTokenAuthService INSTANCE = new MSJavaRefreshTokenAuthService(); + private static final String CLIENT_ID = "00000000402b5328"; + private static final String REDIRECT_URI = "https://login.live.com/oauth20_desktop.srf"; + private static final String SCOPE = "XboxLive.signin XboxLive.offline_access"; private MSJavaRefreshTokenAuthService() {} @@ -38,8 +56,9 @@ private MSJavaRefreshTokenAuthService() {} public CompletableFuture login(MSJavaRefreshTokenAuthData data, @Nullable SFProxy proxyData, Executor executor) { return CompletableFuture.supplyAsync(() -> { try { + var refreshToken = data.refreshToken; var authManager = JavaAuthManager.create(LenniHttpHelper.client(proxyData)) - .login(data.refreshToken); + .login(refreshToken); return AuthHelpers.fromJavaAuthManager(AuthType.MICROSOFT_JAVA_REFRESH_TOKEN, authManager, null); } catch (Exception e) { throw new CompletionException(e); @@ -49,7 +68,17 @@ public CompletableFuture login(MSJavaRefreshTokenAuthData data @Override public MSJavaRefreshTokenAuthData createData(String data) { - return new MSJavaRefreshTokenAuthData(data.strip()); + var t = data.strip(); + if (t.isEmpty()) { + throw new IllegalArgumentException("Invalid refresh token: empty input"); + } + if (looksLikeCookieInput(t)) { + throw new IllegalArgumentException("Invalid refresh token: looks like cookies. Use the Microsoft Cookies import type."); + } + if (t.contains("\n") || t.contains("\r") || t.contains("\t")) { + throw new IllegalArgumentException("Invalid refresh token: contains whitespace"); + } + return new MSJavaRefreshTokenAuthData(t); } @Override @@ -72,5 +101,356 @@ public boolean isExpired(MinecraftAccount account) { || authManager.getMinecraftPlayerCertificates().isExpired(); } + private static boolean looksLikeCookieInput(String input) { + var t = input.strip(); + if (t.isEmpty()) return false; + if (looksLikeCookieJar(t)) return true; + if (looksLikeCookieEditorJson(t)) return true; + return looksLikeCookieHeader(t); + } + + private static boolean looksLikeCookieJar(String input) { + return input.contains("\t") + && (input.contains("__Host-MSAAUTHP") || input.contains("login.live.com")); + } + + private static boolean looksLikeCookieHeader(String input) { + return input.contains("__Host-MSAAUTHP=") + || input.contains("MSPRequ=") + || input.contains("MSPOK=") + || input.contains("PPLState="); + } + + private static boolean looksLikeCookieEditorJson(String input) { + var t = input.strip(); + if (!(t.startsWith("[") && t.endsWith("]"))) return false; + return t.contains("\"domain\"") && t.contains("\"name\"") && t.contains("\"value\""); + } + + private static String cookieJarToCookieHeader(String cookieJar) { + var out = new StringBuilder(); + for (var line : cookieJar.split("\\R")) { + var l = line.strip(); + if (l.isEmpty() || l.startsWith("#")) { + continue; + } + + var parts = l.split("\t"); + if (parts.length < 7) { + continue; + } + + var domain = parts[0].strip(); + domain = stripDomain(domain); + if (!(domain.endsWith("login.live.com") || domain.endsWith(".login.live.com"))) { + continue; + } + + var name = parts[5].strip(); + var value = parts[6].strip(); + if (name.isEmpty() || value.isEmpty()) { + continue; + } + + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name).append('=').append(value); + } + + return out.toString(); + } + + private static String exchangeCookieInputForRefreshToken(String cookieInput, @Nullable SFProxy proxyData) { + var cookieHeader = cookieInputToCookieHeader(cookieInput); + var authorizeUri = URI.create( + "https://login.live.com/oauth20_authorize.srf" + + "?client_id=" + urlEncode(CLIENT_ID) + + "&response_type=code" + + "&response_mode=query" + + "&redirect_uri=" + urlEncode(REDIRECT_URI) + + "&scope=" + urlEncode(SCOPE) + + "&prompt=none" + ); + + var location = ReactorHttpHelper.createReactorClient(proxyData, false) + .responseTimeout(Duration.ofSeconds(20)) + .headers(h -> { + h.set(HttpHeaderNames.USER_AGENT, "SoulFire/" + BuildData.VERSION); + h.set(HttpHeaderNames.COOKIE, cookieHeader); + }) + .get() + .uri(authorizeUri) + .responseSingle((res, content) -> { + if (res.status().codeClass() != HttpStatusClass.REDIRECTION) { + return content.asString(StandardCharsets.UTF_8) + .defaultIfEmpty("") + .flatMap(_ -> Mono.error( + new IllegalStateException("Cookie import failed: unexpected authorize response " + res.status().code()) + )); + } + return Mono.justOrEmpty(res.responseHeaders().get(HttpHeaderNames.LOCATION)); + }) + .block(); + + if (location == null || location.isEmpty()) { + throw new IllegalStateException("Cookie import failed: missing authorize redirect"); + } + + var redirect = URI.create(location); + var expectedRedirect = URI.create(REDIRECT_URI); + if (redirect.getHost() == null + || !expectedRedirect.getHost().equalsIgnoreCase(redirect.getHost()) + || !expectedRedirect.getPath().equals(redirect.getPath())) { + throw new IllegalStateException("Cookie import failed: unexpected redirect to " + redirect.getHost() + redirect.getPath()); + } + var params = parseQueryParams(redirect.getRawQuery()); + var error = params.get("error"); + if (error != null && !error.isEmpty()) { + var description = params.get("error_description"); + throw new IllegalStateException("Cookie import failed: authorize error " + error + textSuffix(description)); + } + + var code = params.get("code"); + if (code == null || code.isEmpty()) { + throw new IllegalStateException("Cookie import failed: missing authorization code"); + } + + var tokenBody = + "client_id=" + urlEncode(CLIENT_ID) + + "&redirect_uri=" + urlEncode(REDIRECT_URI) + + "&scope=" + urlEncode(SCOPE) + + "&grant_type=authorization_code" + + "&code=" + urlEncode(code); + + var tokenJson = ReactorHttpHelper.createReactorClient(proxyData, false) + .responseTimeout(Duration.ofSeconds(20)) + .headers(h -> { + h.set(HttpHeaderNames.USER_AGENT, "SoulFire/" + BuildData.VERSION); + h.set(HttpHeaderNames.ACCEPT, "application/json"); + h.set(HttpHeaderNames.CONTENT_TYPE, "application/x-www-form-urlencoded"); + }) + .post() + .uri(URI.create("https://login.live.com/oauth20_token.srf")) + .send(ByteBufFlux.fromString(Mono.just(tokenBody))) + .responseSingle((res, content) -> { + if (res.status().codeClass() != HttpStatusClass.SUCCESS) { + return content.asString(StandardCharsets.UTF_8) + .defaultIfEmpty("") + .flatMap(body -> Mono.error( + new IllegalStateException("Cookie import failed: token exchange failed " + res.status().code() + oauthErrorSuffix(body)) + )); + } + return content.asString(StandardCharsets.UTF_8); + }) + .block(); + + if (tokenJson == null || tokenJson.isEmpty()) { + throw new IllegalStateException("Cookie import failed: empty token response"); + } + + JsonObject obj; + try { + obj = GsonInstance.GSON.fromJson(tokenJson, JsonObject.class); + } catch (Exception e) { + throw new IllegalStateException("Cookie import failed: invalid token JSON"); + } + + if (obj == null || !obj.has("refresh_token")) { + throw new IllegalStateException("Cookie import failed: missing refresh token"); + } + + var refreshToken = obj.get("refresh_token").getAsString(); + if (refreshToken == null || refreshToken.isEmpty()) { + throw new IllegalStateException("Cookie import failed: empty refresh token"); + } + + return refreshToken; + } + + private static String oauthErrorSuffix(@Nullable String body) { + if (body == null || body.isEmpty()) return ""; + var t = body.strip(); + if (t.isEmpty()) return ""; + + if (t.length() > 2000) { + t = t.substring(0, 2000); + } + + try { + var obj = GsonInstance.GSON.fromJson(t, JsonObject.class); + if (obj != null && obj.has("error")) { + var err = obj.get("error").getAsString(); + var desc = obj.has("error_description") ? obj.get("error_description").getAsString() : null; + if (desc != null && desc.length() > 200) { + desc = desc.substring(0, 200); + } + if (desc != null && !desc.isEmpty()) { + return " (error: " + err + ", description: " + desc + ")"; + } + if (err != null && !err.isEmpty()) { + return " (error: " + err + ")"; + } + } + } catch (Exception ignored) { + } + + return ""; + } + + private static String textSuffix(@Nullable String text) { + if (text == null || text.isEmpty()) return ""; + var t = text.strip(); + if (t.isEmpty()) return ""; + if (t.length() > 200) { + t = t.substring(0, 200); + } + return " (description: " + t + ")"; + } + + private static String cookieInputToCookieHeader(String cookieInput) { + var t = cookieInput.strip(); + t = stripEnclosingQuotes(t); + if (t.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: empty input"); + } + + if (looksLikeCookieJar(t)) { + var cookieHeader = cookieJarToCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no valid login.live.com cookies found"); + } + return cookieHeader; + } + + if (looksLikeCookieEditorJson(t)) { + var cookieHeader = cookieEditorJsonToCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no valid login.live.com cookies found"); + } + return cookieHeader; + } + + if (looksLikeCookieHeader(t)) { + var cookieHeader = normalizeCookieHeader(t); + if (cookieHeader.isEmpty()) { + throw new IllegalArgumentException("Cookie import failed: no usable cookies found"); + } + return cookieHeader; + } + + throw new IllegalArgumentException("Cookie import failed: unrecognized cookie format"); + } + + private static String cookieEditorJsonToCookieHeader(String cookieJson) { + JsonArray arr; + try { + arr = GsonInstance.GSON.fromJson(cookieJson, JsonArray.class); + } catch (Exception e) { + return ""; + } + if (arr == null || arr.isEmpty()) return ""; + + var out = new StringBuilder(); + for (var el : arr) { + if (!el.isJsonObject()) continue; + var obj = el.getAsJsonObject(); + if (!obj.has("name") || !obj.has("value")) continue; + var name = obj.get("name").getAsString(); + var value = obj.get("value").getAsString(); + if (name == null || name.isEmpty() || value == null || value.isEmpty()) continue; + + String domain = null; + if (obj.has("domain")) { + try { + domain = obj.get("domain").getAsString(); + } catch (Exception ignored) { + domain = null; + } + } + if (domain != null && !domain.isEmpty()) { + domain = stripDomain(domain); + if (!(domain.endsWith("login.live.com") || domain.endsWith(".login.live.com"))) { + continue; + } + } + + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name.strip()).append('=').append(value.strip()); + } + + return out.toString(); + } + + private static String normalizeCookieHeader(String cookieHeaderInput) { + var t = stripEnclosingQuotes(cookieHeaderInput.strip()); + t = t.replace("\r\n", "\n"); + t = t.replace("\n", "; "); + t = t.replaceFirst("^(?i)cookie:\\s*", ""); + + var out = new StringBuilder(); + for (var piece : t.split(";")) { + var p = piece.strip(); + if (p.isEmpty()) continue; + var idx = p.indexOf('='); + if (idx <= 0) continue; + var name = p.substring(0, idx).strip(); + var value = p.substring(idx + 1).strip(); + if (name.isEmpty() || value.isEmpty()) continue; + if (!out.isEmpty()) { + out.append("; "); + } + out.append(name).append('=').append(value); + } + return out.toString(); + } + + private static String stripEnclosingQuotes(String v) { + var t = v.strip(); + if (t.length() >= 2) { + var first = t.charAt(0); + var last = t.charAt(t.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return t.substring(1, t.length() - 1).strip(); + } + } + return t; + } + + private static String stripDomain(String domain) { + var d = domain.strip(); + if (d.startsWith("http://")) d = d.substring("http://".length()); + if (d.startsWith("https://")) d = d.substring("https://".length()); + var slash = d.indexOf('/'); + if (slash >= 0) d = d.substring(0, slash); + return d.strip(); + } + + private static Map parseQueryParams(@Nullable String rawQuery) { + var out = new HashMap(); + if (rawQuery == null || rawQuery.isEmpty()) { + return out; + } + + for (var pair : rawQuery.split("&")) { + if (pair.isEmpty()) continue; + var idx = pair.indexOf('='); + var key = idx >= 0 ? pair.substring(0, idx) : pair; + var value = idx >= 0 ? pair.substring(idx + 1) : ""; + out.put(urlDecode(key), urlDecode(value)); + } + return out; + } + + private static String urlEncode(String v) { + return URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + private static String urlDecode(String v) { + return URLDecoder.decode(v, StandardCharsets.UTF_8); + } + public record MSJavaRefreshTokenAuthData(String refreshToken) {} } diff --git a/mod/src/main/java/com/soulfiremc/server/account/service/OnlineChainJavaData.java b/mod/src/main/java/com/soulfiremc/server/account/service/OnlineChainJavaData.java index 51872509b..360180a99 100644 --- a/mod/src/main/java/com/soulfiremc/server/account/service/OnlineChainJavaData.java +++ b/mod/src/main/java/com/soulfiremc/server/account/service/OnlineChainJavaData.java @@ -36,9 +36,30 @@ public record OnlineChainJavaData(JsonObject authChain) implements AccountData { } public JavaAuthManager getJavaAuthManager(@Nullable SFProxy proxyData) { + if (isAccessTokenOnly()) { + throw new UnsupportedOperationException("Access-token-only accounts do not have a full auth chain"); + } return JavaAuthManager.fromJson(LenniHttpHelper.client(proxyData), authChain); } + /** + * Returns true if this is an access-token-only account (no full auth chain). + */ + public boolean isAccessTokenOnly() { + return authChain.has("_accessTokenOnly") && authChain.get("_accessTokenOnly").getAsBoolean(); + } + + /** + * Returns the raw access token for access-token-only accounts, + * or the token from the auth chain for full accounts. + */ + public String getAccessToken(@Nullable SFProxy proxyData) { + if (isAccessTokenOnly()) { + return authChain.get("_accessToken").getAsString(); + } + return getJavaAuthManager(proxyData).getMinecraftToken().getUpToDateUnchecked().getToken(); + } + @SneakyThrows public static OnlineChainJavaData fromProto(MinecraftAccountProto.OnlineChainJavaData data) { return new OnlineChainJavaData( diff --git a/mod/src/main/java/com/soulfiremc/server/bot/BotConnection.java b/mod/src/main/java/com/soulfiremc/server/bot/BotConnection.java index 7c24ee651..19b70a882 100644 --- a/mod/src/main/java/com/soulfiremc/server/bot/BotConnection.java +++ b/mod/src/main/java/com/soulfiremc/server/bot/BotConnection.java @@ -155,7 +155,7 @@ private Minecraft createMinecraftCopy(MinecraftAccount minecraftAccount) { switch (minecraftAccount.accountData()) { case BedrockData ignored -> "bedrock"; case OfflineJavaData ignored -> "offline"; - case OnlineChainJavaData onlineChainJavaData -> onlineChainJavaData.getJavaAuthManager(proxy).getMinecraftToken().getUpToDateUnchecked().getToken(); + case OnlineChainJavaData onlineChainJavaData -> onlineChainJavaData.getAccessToken(proxy); }, Optional.empty(), Optional.empty() diff --git a/mod/src/main/java/com/soulfiremc/server/bot/SFSessionService.java b/mod/src/main/java/com/soulfiremc/server/bot/SFSessionService.java index 077cd2f7e..4a4f9418d 100644 --- a/mod/src/main/java/com/soulfiremc/server/bot/SFSessionService.java +++ b/mod/src/main/java/com/soulfiremc/server/bot/SFSessionService.java @@ -17,6 +17,7 @@ */ package com.soulfiremc.server.bot; +import com.soulfiremc.server.account.MCAuthService; import com.soulfiremc.server.account.service.BedrockData; import com.soulfiremc.server.account.service.OfflineJavaData; import com.soulfiremc.server.account.service.OnlineChainJavaData; @@ -39,12 +40,16 @@ public final class SFSessionService { public void joinServer(String serverId) { var account = botConnection.settingsSource().stem(); + var authService = MCAuthService.convertService(account.authType()); + if (authService.isExpired(account)) { + throw new IllegalStateException("Account authentication is expired; refusing to join server with an expired access token"); + } var joinEndpoint = switch (account.authType()) { - case MICROSOFT_JAVA_CREDENTIALS, MICROSOFT_JAVA_DEVICE_CODE, MICROSOFT_JAVA_REFRESH_TOKEN -> MOJANG_JOIN_URI; + case MICROSOFT_JAVA_CREDENTIALS, MICROSOFT_JAVA_DEVICE_CODE, MICROSOFT_JAVA_REFRESH_TOKEN, MICROSOFT_JAVA_ACCESS_TOKEN -> MOJANG_JOIN_URI; case OFFLINE, MICROSOFT_BEDROCK_CREDENTIALS, MICROSOFT_BEDROCK_DEVICE_CODE -> throw new IllegalArgumentException("Server does not support auth type: " + account.authType()); }; var authenticationToken = switch (account.accountData()) { - case OnlineChainJavaData onlineChainJavaData -> onlineChainJavaData.getJavaAuthManager(botConnection.proxy()).getMinecraftToken().getUpToDateUnchecked().getToken(); + case OnlineChainJavaData onlineChainJavaData -> onlineChainJavaData.getAccessToken(botConnection.proxy()); case OfflineJavaData ignored -> throw new IllegalArgumentException("Invalid auth type: " + account.authType()); case BedrockData ignored -> throw new IllegalArgumentException("Invalid auth type: " + account.authType()); }; diff --git a/mod/src/main/java/com/soulfiremc/server/grpc/MCAuthServiceImpl.java b/mod/src/main/java/com/soulfiremc/server/grpc/MCAuthServiceImpl.java index 52b6c2c83..9814a3485 100644 --- a/mod/src/main/java/com/soulfiremc/server/grpc/MCAuthServiceImpl.java +++ b/mod/src/main/java/com/soulfiremc/server/grpc/MCAuthServiceImpl.java @@ -45,6 +45,12 @@ public void loginCredentials(CredentialsAuthRequest request, StreamObserveret=8JAb*bHKYZu#EtH1BL@6Eh7YbKWb(;uIYZ_ZuL_ndpq zz2~m+`Oo(}N<@{8(QYbar;}Z7a`0eDxSc%2MHfk0bMsIxcF`r080MnO-0b1uF1p;! zULN71tKBr8N4n^GDL=qV#0R-~3?D3?L!3O;MK^kh`5-5cbJ5LSR028CMYnoskdRYc zbi0=dgsgDU9bQNWIm1PFd8tUqS)$NvCm-gfa;|jo95>J9!zD3qFiMUP{pL$@fg~3S zb)-;7iK0gfb&RN5CDbA})p50x7mLzMBxkAQEOS!{FPCJEn@V|wP%GUuhHHgd<)*Q` zTBtQ{8pmseTIZ&Tyk4j}H%;LUPTuIImAuJK>$uX%$GT}XS4#0_C)dmGaZWzoOHMvX zU?Zt}$t}qt^3mioR6fOgl5n;Q$H`)Zh-e*ka?HgYZff8iPCms=I(NEx zC!gx%)7;d`r;9z#km_eT`76SEmYagy>EvB5KHE)UVLQjk=eqbjH%0h-Ctu*E9sE@% zf6Yyue4&%!_fzHfVmF=6mpJ)SH=W6s$>;0xxm-ZMAuau;mxl3G($=e;d<~OU6AI~( z>YzUw)uT+#MIC`)lOADmx5i@a)xm%siZM;E4e2poP!ELyGb`uJ@->Fr+QOlzPwF(P zI=I;}gwnhC_?d`!pqdyi1hpNM&=0FS6d<7Hnx3>n`8hvenklq;aH^=-Pu~r{A zd~M+-J?LANfftzWCVfYsQQxq$U1##vZu9T(`-1*Zi?1#g34~fuW2jMMrM`2mKY%8T zGAc#&#*RoJw$o=8pr*^HS<}SiF)DS$0zu!#jWvr=I;^o3xO#0M8q;AMnB|xpiYw06 zuvpC(G+BJr$p`=~js~I~8^YW5P_#DOxIMWEYZ|ucjWJOSW>A%wX5=Yzxe^WD!F7R_ zkU!QDfql00i+N#=+DU#W*+Qih91H*12L)}~wfo%4K%}FwpTn(ig zqk6wn-+NuVu zIl|*3UkOoyJqf{UBjM&i5Pi61Kt-Ekl<tg=K?W_Fl z$}3ohLEFZNj_!<6)j!+!x5rE{7ZB(F|E?#ht;l zsK1=){=gI&(VHWBv~^Wt2|sg6uJD;DK9GA!XQSROnTD3#-*XA^)9Bx{TGmi0-FpsWz{7%qfCRQNGx$%`_q05b_)mKBU;1Z z?I}B7EJj-Nm~}Y73GE0(16XWUg+k$&I$6LJ?5z!k+Zw{# zeamFCjx|mbR2c=K7>8*~1g3>K4Vg41)3^aFtugn#>L6ky;8d4MK8o-pswGY%O5-&% zoJR~vRF@+OZ@i}cP{NBUX|`RIvoZEFAs(pIP#mEg$Ty9i5f(nIoUMx!1f|kTLz%g*zgR`cifBH8Mr^3}Mq~5ADT{ zvww#n;Nt5v`Zn!0S@SMAw`zPF-|pmZX?zFYDJ6Gld^dj^ z=je;V;aD^V_*HtWHQW>(R>a@&@ICms*TeT|d_RBJ$-6avfFIQOdwdTJB~8h!Pqugr zw1+(0g;S9{c@qH@oZTMoVVadErxg&v+Uwzm^B0;3XyDxAVR-GZ>_SzS$aF3W=I?v> z5iF;b8I3NXOQqmhjV`0FYy2EPuhHf74UJ!*_cgkjuF?2K{vp$} z?8>Jv6-2;G8eK2kFY}Ki`HDt2A_?kMjczumA8T~0LH$Id+YRcc8r@+~uW59bLA{P` zR(A0jhq8J4C8O?VOh;wswlAczt<5z0CS8G%r6j5KrE!d_(Uo*nPSe=YjWw5t-x%iP zpL^&Tjo;*7h=RXF##!DZh^PY!(jGy;C^i%wN;Gx$w z`WjuR@vr$qjebFIX#5*`pJ}Y6WKA%rxA=oNgN*5k-7F3stNo$Lu`+W5QnsqDsw^f~ zN7Kq0^hSS2R4)s}%1-e|%Z!uCrm|B4h$9`bGJk?c)t^zep;a$K{YY6WYRAH5QQUi# z1wv*17Jnc#L)!6Mtn;(mUOG-hKe+nPD6xV#Sx)1+bRAdNR(LoiLs*mz~t&W5jOuv7<#5QUiaLhx$KcF0 zT9R6v*aj@X8!^WqIY${VS=Pn@!p1rZ2DD*`tp;R`a}?&d-~Cb{M>#yx<^@MzEBl+%y>>9~vn)8<D~T#NX6}24FQ7!w8$4Az;|1DC2pBI%e2xv@-MNI4Llt&jWqDE*J!ru=mcSN~R6o z661k|(y9;r&4+k04G+~rNmIofQ(Ri)??YKvEF7N$2IE|;+K*)!w4c`Z5!jN>_CQ2l zroaoed0W$niG3-Bea9woy}CO&j%j@^{G>Q!dos764=HQ&2DO4)ACT+vhP1`7J}~oF z(+X96K-T3_Gt-F8%WE6^iy99+eQ0YgIx-K{OxmWe<@@Ri4TkFJ6JzH0w<KuC<4ZGzIYclu`3#j$v3!QXB2lr(^gabyVMxNF+$7nL8*oXTAr4E; z?YWTqm;iE^FNxC}Hjq{!2G6s5;Xq37#FOe6u3?*&`$J9g7;vSbZcYenPUOfXs-B9Q z+gJ1uYhpF+E_bxHTys`2a;(+9kxrja#L9uFieJX-b-7WME6$DW8^Vj#6S(_x**0sq z&SBLg9xEE>%V7()>Ft)zkaI*N3Ae>q@XgEB&`hDp>JiW8VomBr=?jn??M~t}q~)lc z)HM6lnZ@~;t*43ettaDhgKt?Jht^0Z%!U{ENUA@Fz!^k*L!_2{Dih%;X%S?0S({*6 z@L28e01toup#B(uX;&_r+QVVC1L3^mX%+xzzv#cz0aJz3FpM~9=Ojk?YZH6*{0j|{xq0ou&qS{F2ZMEin5+lz3!^kVg_@KOUGoyZSC8K_EA)|hA9ix76388**>7agbIfKKDYe75cI`HGVC6T@X zY23FY(l;TETbD%o7Nl|al1Sf%G;UxL>2D#8dzeJ}PNZ=glSto9V`(sMGrxnJd!RuT z6+jE3q2+tYQD4zRgL7l8X9p+OpEKo%*s;1$zm`2eODx;+|m6p+LT8?iGoj@z-WLin*Q!QM&3N~3yPl3m> z-O~x%Jq_DEpltUbeb2PrJ{lypoMw zG?zBRRrN?6M_cK5iqeU625rH}+=?;hhf^AW&?rXRMm+`jT zFdcngxeZ2C1{R0BuEMp1yw~Lp9o=D58bq=d&-qNJ@VvTI-(;&tGlT0t7&vjz< zaJ{#^hfdySYG%%6sgfUt9CT}Wp1PC>_{5A<<8v{Ru^OhtdG4|HsAGNYfG zgnpVrpOT9HY9Hu$OJbs5g|RO4!g|9prwh6{9-+UCU&~LG;-E8>#4~GAa&$#EeZ>ff zRUpqY$ZC+gKw5KMiJDtpqnT*a0=#2c3hC?U=SoLoeZAha?@Stp{5R2w?3TuRA>`bH zSTn@z+K)pz;>y|hSzF&t=N!8S(Q$TKg4s(z}?R-$THA9|8FTn?oNrjm6-4%XFg;29l9i z2m@?M?F~C@%!MZN+o&u{vUl*y5ch5dAYUrbDSZOH2W^chz^>8kRG`j2fj*#LX8@{9 z1=`ss(1-M!3_x>Jflloc2rpGK0If|0I;~Hj-_sw^jnSRSZligrKxbtDGPchjp_|yf z_s5KY3sV7iWd;0G3gDkJ0v??Tcy?C6k5d5uk`b^f74V#_fPYN^{98uA>QumUvjYA- z1@M!MfJ;&V&&vw7@mwg{4J3#8!+l_HOeP@_M7D8AfD3_#bRdB=!{3=f<)V)EDL^qZkFo zCZiaSQ;bbUF+0>I0~|aEdrv3+|3I?&aBvYeI)p{uAZ&W@&*Q?uLLq&X20KPLMma`e zi6Hyl+sMPCz;hs%@@SJP<3mkqJWnvGNj%x4rgFJSP2=e%<>Q%(I)P8*EhgpX29s*! gCX;IB7L#h_fJtrR?IsoEHj@f*SW#E-m3+nj0hC!*qyPW_ literal 0 HcmV?d00001 diff --git a/proto/src/main/proto/soulfire/common.proto b/proto/src/main/proto/soulfire/common.proto index 797237409..494aa57f9 100644 --- a/proto/src/main/proto/soulfire/common.proto +++ b/proto/src/main/proto/soulfire/common.proto @@ -66,6 +66,15 @@ enum AccountTypeCredentials { // Useful for re-authenticating accounts without requiring credentials again. // Payload format: ["refresh_token"] for each account. MICROSOFT_JAVA_REFRESH_TOKEN = 5; + // Microsoft Java Edition authentication using a raw Minecraft access token. + // Useful for importing accounts from other launchers or tools. + // Payload format: ["access_token"] for each account. + // Note: Access token accounts cannot be refreshed. + MICROSOFT_JAVA_ACCESS_TOKEN = 6; + // Microsoft Java Edition authentication using browser cookies for login.live.com. + // The server exchanges cookies for an OAuth refresh token, then authenticates normally. + // Payload format: a cookie jar / Cookie header / cookie export for each account. + MICROSOFT_JAVA_COOKIES = 7; } // Authentication service types that use the OAuth 2.0 Device Code flow. @@ -110,6 +119,10 @@ message MinecraftAccountProto { // Authenticated via Microsoft Java refresh token. // Account data stored in OnlineChainJavaData. MICROSOFT_JAVA_REFRESH_TOKEN = 7; + // Authenticated via raw Minecraft access token. + // Account data stored in OnlineChainJavaData. + // Cannot be refreshed. + MICROSOFT_JAVA_ACCESS_TOKEN = 8; } // Authentication chain data for online Java Edition accounts. diff --git a/proto/src/main/proto/soulfire/mc-auth.proto b/proto/src/main/proto/soulfire/mc-auth.proto index 053bbae1f..6539c6cf9 100644 --- a/proto/src/main/proto/soulfire/mc-auth.proto +++ b/proto/src/main/proto/soulfire/mc-auth.proto @@ -21,6 +21,7 @@ message CredentialsAuthRequest { // - MICROSOFT_BEDROCK_CREDENTIALS: Payload format is "email:password" // - OFFLINE: Payload is the username to use (no authentication performed) // - MICROSOFT_JAVA_REFRESH_TOKEN: Payload is a Microsoft refresh token + // - MICROSOFT_JAVA_COOKIES: Payload is a cookie import (cookie jar / Cookie header / export) AccountTypeCredentials service = 2; // List of authentication credentials/data strings. diff --git a/start.bat b/start.bat new file mode 100644 index 000000000..ce7e9336e --- /dev/null +++ b/start.bat @@ -0,0 +1,39 @@ +@echo off +setlocal + +if not "%SF_PWD%"=="" ( + cd /d "%SF_PWD%" +) + +if "%SF_RAM%"=="" ( + echo SF_RAM is not set. Defaulting to 2G. + set "SF_RAM=2G" +) + +if "%SF_JAR%"=="" ( + echo SF_JAR is not set. Building and locating locally built jar... + call .\gradlew.bat :dedicated-launcher:uberJar --no-daemon --max-workers=1 + if errorlevel 1 ( + echo Error: Gradle build failed. + exit /b 1 + ) + + for /f "delims=" %%F in ('dir /b /a-d /o-d "dedicated-launcher\build\libs\SoulFireDedicated-*.jar" 2^>nul') do ( + echo %%F | findstr /I /C:"unshaded" /C:"sources" /C:"javadoc" >nul + if errorlevel 1 if "%SF_JAR%"=="" set "SF_JAR=dedicated-launcher\build\libs\%%F" + ) +) + +if "%SF_JAR%"=="" ( + echo Error: Could not find built jar in dedicated-launcher\build\libs. + echo Please run '.\gradlew build' first or set the SF_JAR environment variable. + exit /b 1 +) + +if not exist "%SF_JAR%" ( + echo Error: Unable to access jarfile "%SF_JAR%" + exit /b 1 +) + +echo Starting SoulFire dedicated server... +java -Xmx%SF_RAM% %SF_JVM_FLAGS% -XX:+EnableDynamicAgentLoading -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:+UseCompactObjectHeaders -XX:+AlwaysActAsServerClassMachine -XX:+UseNUMA -XX:+UseFastUnorderedTimeStamps -XX:+UseVectorCmov -XX:+UseCriticalJavaThreadPriority -Dsf.flags.v2=true -jar "%SF_JAR%" diff --git a/untracked.txt b/untracked.txt new file mode 100644 index 0000000000000000000000000000000000000000..7cf5e7a54743ea4a16aa3d1865355239c0034020 GIT binary patch literal 30838 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