From 2de394ebbf8b8415e91bbbf2d198a4954406513f Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 25 Sep 2026 15:22:37 +0200 Subject: [PATCH] Authenticate through Mojang's session server; check client version Stop sending the Minecraft access token to the Saturn server. The connect loop now: 1. fetches GET /versions and compares this client's mod version: supported -> connect; deprecated -> warn, connect; neither -> warn, don't connect (warnings are shown once per status change) 2. requests auth_challenge with the username to get a server id 3. calls Mojang's session `join` with the access token and server id (the token only goes to Mojang); a refused token gives up 4. requests auth_verify, which the server confirms with `hasJoined` Add SaturnProvider.getModVersion() and showWarning() for the version check and its toast. Refs saturnclientmc/saturnclient#7 Co-Authored-By: Claude Opus 5.5 --- .../saturnclient/client/MojangSession.java | 60 ++++++++ .../saturnclient/client/ServiceClient.java | 133 ++++++++++++++++-- .../saturnclient/client/ServiceMethods.java | 16 ++- .../common/provider/SaturnProvider.java | 6 + 4 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/saturnclient/client/MojangSession.java diff --git a/src/main/java/org/saturnclient/client/MojangSession.java b/src/main/java/org/saturnclient/client/MojangSession.java new file mode 100644 index 0000000..deadb0e --- /dev/null +++ b/src/main/java/org/saturnclient/client/MojangSession.java @@ -0,0 +1,60 @@ +package org.saturnclient.client; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.UUID; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Mojang's session server "join" call, the same one vanilla makes when joining + * an online-mode server. The access token is only ever sent to Mojang; the + * Saturn server then confirms the join with {@code hasJoined}. + */ +public final class MojangSession { + private static final URI JOIN_URI = URI.create("https://sessionserver.mojang.com/session/minecraft/join"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public enum JoinResult { + JOINED, + /** Mojang refused the access token; retrying won't help. */ + REJECTED, + /** Network or server error; worth retrying. */ + FAILED + } + + private MojangSession() { + } + + public static JoinResult join(HttpClient http, String accessToken, UUID profile, String serverId) { + ObjectNode body = MAPPER.createObjectNode() + .put("accessToken", accessToken) + .put("selectedProfile", profile.toString().replace("-", "")) + .put("serverId", serverId); + + HttpRequest request = HttpRequest.newBuilder(JOIN_URI) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + + try { + int status = http.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + + if (status == 204 || status == 200) { + return JoinResult.JOINED; + } + + return status == 401 || status == 403 ? JoinResult.REJECTED : JoinResult.FAILED; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return JoinResult.FAILED; + } catch (Exception e) { + return JoinResult.FAILED; + } + } +} diff --git a/src/main/java/org/saturnclient/client/ServiceClient.java b/src/main/java/org/saturnclient/client/ServiceClient.java index 760085a..2371502 100644 --- a/src/main/java/org/saturnclient/client/ServiceClient.java +++ b/src/main/java/org/saturnclient/client/ServiceClient.java @@ -1,5 +1,9 @@ package org.saturnclient.client; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.time.Duration; import java.util.UUID; import java.util.concurrent.CompletionException; @@ -13,6 +17,8 @@ import org.saturnclient.common.ref.game.MinecraftClientRef; import org.saturnclient.cosmetics.Cloaks; import org.saturnclient.cosmetics.Hats; +import com.fasterxml.jackson.databind.ObjectMapper; + import dev.selimaj.session.Session; import dev.selimaj.session.types.SessionErrorException; @@ -31,6 +37,22 @@ public class ServiceClient { private static final AtomicBoolean CONNECTING = new AtomicBoolean(); private static volatile boolean stopping = false; + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)) + .build(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private enum VersionStatus { + SUPPORTED, + /** Still accepted, but the player should update. */ + DEPRECATED, + /** Not accepted; don't connect. */ + UNSUPPORTED + } + + /** Last status the player was warned about, so reconnects don't repeat the toast. */ + private static volatile VersionStatus warnedStatus = VersionStatus.SUPPORTED; + public static void initialize() { Providers.saturn.getClient().onClientStopping(() -> { stopping = true; @@ -130,6 +152,19 @@ public class ServiceClient { } uuid = playerUuid; + + VersionStatus version; + try { + version = checkVersion(); + } catch (Exception e) { + Providers.saturn.logError("Unable to fetch the Saturn version manifest: " + rootMessage(e)); + return ConnectResult.RETRY; + } + + if (version == VersionStatus.UNSUPPORTED) { + return ConnectResult.GIVE_UP; + } + Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid); Session s; @@ -142,17 +177,46 @@ public class ServiceClient { ServiceMethods.Types.Player response; try { - response = s.request(ServiceMethods.Authenticate, accessToken, REQUEST_TIMEOUT).get(); - } catch (ExecutionException e) { - s.close(); + // 1. The server hands out a one-time server id for this connection. + String serverId; + try { + serverId = s.request(ServiceMethods.AuthChallenge, username, REQUEST_TIMEOUT).get(); + } catch (ExecutionException e) { + s.close(); - if (e.getCause() instanceof SessionErrorException rejected) { - Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage()); - return ConnectResult.GIVE_UP; + if (e.getCause() instanceof SessionErrorException rejected) { + Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage()); + return ConnectResult.GIVE_UP; + } + + Providers.saturn.logError("Authentication failed: " + rootMessage(e)); + return ConnectResult.RETRY; } - Providers.saturn.logError("Authentication failed: " + rootMessage(e)); - return ConnectResult.RETRY; + // 2. Join with it at Mojang; the access token only goes to Mojang. + switch (MojangSession.join(HTTP, accessToken, playerUuid, serverId)) { + case JOINED -> { + } + case REJECTED -> { + s.close(); + Providers.saturn.logError("Mojang rejected the Minecraft session"); + return ConnectResult.GIVE_UP; + } + case FAILED -> { + s.close(); + Providers.saturn.logError("Unable to reach the Mojang session server"); + return ConnectResult.RETRY; + } + } + + // 3. The server confirms the join with Mojang and logs us in. + try { + response = s.request(ServiceMethods.AuthVerify, null, REQUEST_TIMEOUT).get(); + } catch (ExecutionException e) { + s.close(); + Providers.saturn.logError("Authentication failed: " + rootMessage(e)); + return ConnectResult.RETRY; + } } catch (InterruptedException e) { s.close(); return ConnectResult.GIVE_UP; @@ -183,6 +247,59 @@ public class ServiceClient { return ConnectResult.CONNECTED; } + /** + * Checks this client's version against the server's manifest and warns the + * player if it's deprecated or unsupported. + */ + private static VersionStatus checkVersion() throws Exception { + URI server = URI.create(SERVER_URI); + String scheme = "wss".equals(server.getScheme()) ? "https" : "http"; + URI manifestUri = new URI(scheme, server.getAuthority(), "/versions", null, null); + + HttpResponse res = HTTP.send( + HttpRequest.newBuilder(manifestUri).timeout(REQUEST_TIMEOUT).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + if (res.statusCode() != 200) { + throw new IllegalStateException("GET /versions returned " + res.statusCode()); + } + + ServiceMethods.VersionManifest manifest = MAPPER.readValue(res.body(), ServiceMethods.VersionManifest.class); + + // Drop build metadata such as "+1.21.11": the manifest lists mod versions. + String version = Providers.saturn.getModVersion().split("\\+", 2)[0]; + + VersionStatus status; + if (manifest.supported() != null && manifest.supported().contains(version)) { + status = VersionStatus.SUPPORTED; + } else if (manifest.deprecated() != null && manifest.deprecated().contains(version)) { + status = VersionStatus.DEPRECATED; + } else { + status = VersionStatus.UNSUPPORTED; + } + + if (status != warnedStatus) { + warnedStatus = status; + + switch (status) { + case DEPRECATED -> { + Providers.saturn.logError("Saturn Client " + version + " is deprecated"); + Providers.saturn.showWarning("Saturn Client update available", + "Version " + version + " is deprecated and will stop working soon. Please update."); + } + case UNSUPPORTED -> { + Providers.saturn.logError("Saturn Client " + version + " is no longer supported, not connecting"); + Providers.saturn.showWarning("Saturn Client is outdated", + "Version " + version + " is no longer supported. Update to use cosmetics and emotes."); + } + case SUPPORTED -> { + } + } + } + + return status; + } + private static void onDisconnected(Session closed) { if (session != closed) { return; diff --git a/src/main/java/org/saturnclient/client/ServiceMethods.java b/src/main/java/org/saturnclient/client/ServiceMethods.java index ac77b4e..fc65d84 100644 --- a/src/main/java/org/saturnclient/client/ServiceMethods.java +++ b/src/main/java/org/saturnclient/client/ServiceMethods.java @@ -43,11 +43,25 @@ public class ServiceMethods { // ========================= // AUTH // ========================= - public static final Method Authenticate = new Method<>("auth", + /** Username in, random server id out, to pass to Mojang's session join. */ + public static final Method AuthChallenge = new Method<>("auth_challenge", String.class, + String.class, + String.class); + + /** After joining at Mojang: the server verifies with hasJoined. */ + public static final Method AuthVerify = new Method<>("auth_verify", + Void.class, Types.Player.class, String.class); + // ========================= + // VERSION MANIFEST (GET /versions) + // ========================= + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + public record VersionManifest(java.util.List supported, java.util.List deprecated) { + } + // ========================= // EQUIP // ========================= diff --git a/src/main/java/org/saturnclient/common/provider/SaturnProvider.java b/src/main/java/org/saturnclient/common/provider/SaturnProvider.java index 995fe2a..5efbf2e 100644 --- a/src/main/java/org/saturnclient/common/provider/SaturnProvider.java +++ b/src/main/java/org/saturnclient/common/provider/SaturnProvider.java @@ -18,4 +18,10 @@ public interface SaturnProvider { public void logError(String message, Throwable throwable); public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi); + + /** The Saturn Client mod version, e.g. {@code 0.1.0-beta3+1.21.11}. */ + public String getModVersion(); + + /** Shows a warning toast. Safe to call from any thread. */ + public void showWarning(String title, String message); }