From 4e7ffe72320326d3e87c83149c262bc9d391a755 Mon Sep 17 00:00:00 2001 From: hendrik Date: Mon, 13 Jul 2026 21:08:14 +0200 Subject: [PATCH] feat(link): /link and /unlink a Microsoft account from in-game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friend sync needs a Microsoft refresh token for the player, and Microsoft will not hand one to a game client — the consent has to happen in a browser. So the command's whole job is to fetch a consent URL from forge and put it in chat as something clickable. The UUID sent to forge is only a claim. forge verifies it against the Minecraft profile behind the completed consent and refuses a mismatch, so nothing here has to be trusted — which is why this can be a plain chat command with no proof attached. Every call is async: a Velocity command runs on the netty thread and forge has to reach Microsoft before it can answer, so blocking would stall the proxy. The HTTP client mirrors plugin-grounds-platform's WhitelistApiClient — JDK HttpClient, nothing to shade, and the token read from the environment per request rather than held in a field, because a credential in a field ends up in a toString() sooner or later. Without GROUNDS_FORGE_URL the commands are not registered at all, rather than registered and guaranteed to fail. Note: this is the first command in plugin-player. feat/permission adds commands too and touches the same registration site — expect a trivial conflict there. --- .../kotlin/gg/grounds/GroundsPluginPlayer.kt | 32 ++++++ .../gg/grounds/config/MessagesConfig.kt | 8 ++ .../kotlin/gg/grounds/link/ForgeLinkClient.kt | 76 +++++++++++++ .../kotlin/gg/grounds/link/LinkCommand.kt | 105 ++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 velocity/src/main/kotlin/gg/grounds/link/ForgeLinkClient.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index b277c8c..21076eb 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -8,7 +8,10 @@ import com.velocitypowered.api.plugin.Dependency import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.plugin.annotation.DataDirectory import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.config.MessagesConfig import gg.grounds.config.MessagesConfigLoader +import gg.grounds.link.ForgeLinkClient +import gg.grounds.link.LinkCommand import gg.grounds.listener.PlayerConnectionListener import gg.grounds.presence.PlayerHeartbeatScheduler import gg.grounds.presence.PlayerPresenceService @@ -72,10 +75,39 @@ constructor( PlayerSessionQueryImpl(playerPresenceService), ) + registerLinkCommands(messages) + heartbeatScheduler.start() logger.info("Configured player presence gRPC client (target={})", target) } + /** + * /link + /unlink talk to forge over HTTP, which needs the platform context forge injects into + * pushed workloads. Outside that context (a bare local proxy, say) the env vars are absent — + * skip the commands rather than register ones that can only ever fail. + */ + private fun registerLinkCommands(messages: MessagesConfig) { + val forgeUrl = System.getenv("GROUNDS_FORGE_URL")?.trim()?.trimEnd('/') + if (forgeUrl.isNullOrEmpty()) { + logger.info("GROUNDS_FORGE_URL is not set; /link is unavailable") + return + } + + // Read the token per call, not once here: it comes from a mounted Secret and holding it in + // a field is how credentials end up in logs. + val client = + ForgeLinkClient( + forgeUrl = forgeUrl, + tokenProvider = { + System.getenv("GROUNDS_TOKEN")?.trim()?.takeIf { it.isNotEmpty() } + }, + ) + + proxy.commandManager.register(LinkCommand.create(client, messages, logger)) + proxy.commandManager.register(LinkCommand.createUnlink(client, messages, logger)) + logger.info("Registered /link and /unlink (forgeUrl={})", forgeUrl) + } + @Subscribe fun onShutdown(event: ProxyShutdownEvent) { ProxyServiceRegistry.unregister(PlayerSessionQuery::class.java) diff --git a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt index 946a6d0..d6b3b29 100644 --- a/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt +++ b/velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt @@ -5,4 +5,12 @@ data class MessagesConfig( val alreadyOnline: String = "You are already online.", val invalidRequest: String = "Invalid login request.", val genericError: String = "Unable to create player session.", + // /link and /unlink. The consent itself happens in a browser, so the + // prompt's job is to explain why a player is being sent out of the game. + val linkPlayersOnly: String = "Only players can link a Microsoft account.", + val linkWorking: String = "Preparing your Microsoft sign-in...", + val linkPrompt: String = "Sign in with Microsoft to find friends who play here:", + val linkClickHere: String = "[Click to connect]", + val linkFailed: String = "Could not start the link right now. Please try again.", + val unlinkDone: String = "Your Microsoft account has been disconnected.", ) diff --git a/velocity/src/main/kotlin/gg/grounds/link/ForgeLinkClient.kt b/velocity/src/main/kotlin/gg/grounds/link/ForgeLinkClient.kt new file mode 100644 index 0000000..b07bc9f --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/link/ForgeLinkClient.kt @@ -0,0 +1,76 @@ +package gg.grounds.link + +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.CompletableFuture +import tools.jackson.databind.json.JsonMapper + +/** + * HTTP client for forge's Minecraft-link endpoints. + * + * Mirrors plugin-grounds-platform's WhitelistApiClient: the JDK's built-in HttpClient (already on + * the classpath, nothing to shade) and the token pulled from the environment per request rather + * than held in a field — a credential in a field ends up in some `toString()` sooner or later. + * + * Every call is async. A Velocity command runs on the netty thread, and forge has to talk to + * Microsoft before it can answer, so blocking here would stall the whole proxy. + */ +class ForgeLinkClient( + private val forgeUrl: String, + private val tokenProvider: () -> String?, + private val httpClient: HttpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(), +) { + + /** forge could not be asked, or refused. The message is for the log, not for the player. */ + class ForgeLinkException(message: String) : RuntimeException(message) + + private val mapper = JsonMapper.builder().build() + + private fun request(path: String): HttpRequest.Builder { + val token = + tokenProvider() + ?: throw ForgeLinkException("GROUNDS_TOKEN is not set; refusing to call forge") + return HttpRequest.newBuilder(URI.create("$forgeUrl$path")) + .header("Authorization", "Bearer $token") + .header("Accept", "application/json") + .timeout(Duration.ofSeconds(20)) + } + + /** + * Ask forge for a Microsoft consent URL for this player. + * + * The UUID is only a claim at this point — forge verifies against the Minecraft profile behind + * the completed consent and refuses a mismatch, so nothing here has to be trusted. + */ + fun startLink(playerId: UUID): CompletableFuture { + val body = mapper.writeValueAsString(mapOf("minecraftUuid" to playerId.toString())) + val req = + request("/v1/minecraft/link/start") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()).thenApply { res -> + if (res.statusCode() / 100 != 2) { + throw ForgeLinkException("link/start failed (status=${res.statusCode()})") + } + mapper.readTree(res.body())["authorizeUrl"]?.asString() + ?: throw ForgeLinkException("link/start returned no authorizeUrl") + } + } + + /** Drop the stored Microsoft token. Forgetting an absent link is a success, not an error. */ + fun unlink(playerId: UUID): CompletableFuture { + val req = request("/v1/minecraft/$playerId/link").DELETE().build() + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.discarding()).thenAccept { res -> + if (res.statusCode() / 100 != 2) { + throw ForgeLinkException("unlink failed (status=${res.statusCode()})") + } + } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt b/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt new file mode 100644 index 0000000..d081056 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt @@ -0,0 +1,105 @@ +package gg.grounds.link + +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.velocitypowered.api.command.BrigadierCommand +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import gg.grounds.config.MessagesConfig +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import org.slf4j.Logger + +/** + * `/link` — connect a Microsoft account so friend sync can read the player's Minecraft friends, and + * `/unlink` to revoke it. + * + * The consent has to happen in a browser (Microsoft will not hand out a token to a game client), so + * the command's whole job is to fetch a URL from forge and put it in chat as something the player + * can click. + */ +object LinkCommand { + + fun create( + client: ForgeLinkClient, + messages: MessagesConfig, + logger: Logger, + ): BrigadierCommand { + val node = + LiteralArgumentBuilder.literal("link").executes { ctx -> + val player = ctx.source as? Player + if (player == null) { + ctx.source.sendMessage( + Component.text(messages.linkPlayersOnly, NamedTextColor.RED) + ) + return@executes 1 + } + + player.sendMessage(Component.text(messages.linkWorking, NamedTextColor.GRAY)) + + client + .startLink(player.uniqueId) + .thenAccept { url -> player.sendMessage(linkMessage(url, messages)) } + .exceptionally { err -> + // The player gets a flat "couldn't do it"; the reason is ours to debug. + logger.warn( + "Failed to start Minecraft link (player={})", + player.uniqueId, + err, + ) + player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED)) + null + } + 1 + } + + return BrigadierCommand(node.build()) + } + + fun createUnlink( + client: ForgeLinkClient, + messages: MessagesConfig, + logger: Logger, + ): BrigadierCommand { + val node = + LiteralArgumentBuilder.literal("unlink").executes { ctx -> + val player = ctx.source as? Player + if (player == null) { + ctx.source.sendMessage( + Component.text(messages.linkPlayersOnly, NamedTextColor.RED) + ) + return@executes 1 + } + + client + .unlink(player.uniqueId) + .thenRun { + player.sendMessage( + Component.text(messages.unlinkDone, NamedTextColor.GREEN) + ) + } + .exceptionally { err -> + logger.warn("Failed to unlink (player={})", player.uniqueId, err) + player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED)) + null + } + 1 + } + + return BrigadierCommand(node.build()) + } + + /** + * The URL is long and single-use, so it is attached as a click target rather than pasted into + * chat — a wrapped URL that the player has to retype by hand is not a link flow. + */ + private fun linkMessage(url: String, messages: MessagesConfig): Component = + Component.text(messages.linkPrompt, NamedTextColor.YELLOW) + .append(Component.space()) + .append( + Component.text(messages.linkClickHere, NamedTextColor.AQUA) + .decorate(TextDecoration.UNDERLINED) + .clickEvent(ClickEvent.openUrl(url)) + ) +}