From e4fda182764a68906f28c56c8d62ca2a8b245120 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:07:14 +0000 Subject: [PATCH] Link Discord from the user page and document the setup The user page gets a Link Discord button. It asks the controller for a one-time ticket bound to the logged in user, then goes through the authorize route with it. The callback links the Discord account to that user directly, without the in-game code, and goes back to the user page. The link flow always shows the Discord consent screen. The README covers registering the redirect URL and the config. Co-Authored-By: Claude Opus 5.5 --- exp_discord/README.md | 43 +++++++ exp_discord/controller.ts | 111 +++++++++++++----- exp_discord/index.ts | 1 + exp_discord/messages.ts | 35 ++++++ exp_discord/test/controller.test.js | 63 ++++++++++ .../web/components/UserDiscordLink.tsx | 38 +++++- 6 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 exp_discord/README.md diff --git a/exp_discord/README.md b/exp_discord/README.md new file mode 100644 index 00000000..0c2402ba --- /dev/null +++ b/exp_discord/README.md @@ -0,0 +1,43 @@ +# ExpGaming - Discord + +Lets players log in to the web interface with Discord, and links their Factorio accounts to a Discord account. It only uses the OAuth2 `identify` scope, so no bot is needed. + +## Setup + +1. Create an application in the [Discord developer portal](https://discord.com/developers/applications). +2. Under OAuth2, copy the Client ID and reset the Client Secret to get one. +3. Under OAuth2 → Redirects, add `api/exp_discord/callback`. For a controller with `controller.public_url` set to `https://example.com/cluster/` that is `https://example.com/cluster/api/exp_discord/callback`. It must match exactly, including the trailing path and http or https. +4. Set the config on the controller: + +``` +npx clusterioctl controller config set controller.public_url https://example.com/cluster/ +npx clusterioctl controller config set exp_discord.client_id +npx clusterioctl controller config set exp_discord.client_secret +``` + +`controller.public_url` has to be set, because the redirect URL is built from it and Discord rejects any URL that isn't registered. + +## Config + +All fields are on the controller. + +| Field | Default | Description | +|---|---|---| +| `exp_discord.client_id` | | Application ID of the Discord application. | +| `exp_discord.client_secret` | | OAuth2 client secret of the Discord application. Only readable on the controller. | +| `exp_discord.max_accounts` | 3 | How many Factorio accounts one Discord account can link. | + +## Linking + +There are two ways to link a Factorio account: + +- On the user page, a logged in user clicks Link Discord and approves it on Discord. +- On the login page, a player logs in with Discord and gets a code, then types `/link-discord CODE` on any server. This is how a player who can't log in to the web interface yet links their account. + +Linking a Factorio account that is already linked to another Discord account moves it. `/link-discord` without a code shows the current link, and `/unlink-discord` removes it. The user page has an Unlink button too. + +Users can always see and remove their own link. The `exp_discord.link.get` and `exp_discord.link.delete` permissions allow it for other users. + +Once linked, the Discord tab on the login page has a "Log in as" button for each linked account. Banned users can't log in this way. + +Links are stored in `database/exp_discord/accounts.json` on the controller. diff --git a/exp_discord/controller.ts b/exp_discord/controller.ts index b9a99379..2edc6f4d 100644 --- a/exp_discord/controller.ts +++ b/exp_discord/controller.ts @@ -15,6 +15,7 @@ const codeLetters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; type DiscordUser = { id: string, name: string }; type Session = DiscordUser & { expiresMs: number, code?: string }; type LinkCode = { sessionId: string, expiresMs: number }; +type WebLink = { playerName: string, expiresMs: number }; /** What an http route answers with, kept apart from express so it can be tested. */ export type RouteResult = @@ -55,6 +56,10 @@ export class ControllerPlugin { sessions = new Map(); /** Codes waiting to be used in game, by code */ codes = new Map(); + /** Links started from the user page, by ticket until authorize turns them into a state */ + linkTickets = new Map(); + /** Links waiting for the Discord callback, by OAuth state */ + pendingLinks = new Map(); cleanupInterval?: ReturnType; private constructor( @@ -80,6 +85,7 @@ export class ControllerPlugin { controller.handle(messages.DiscordLinkRequest, plugin.handleLinkRequest.bind(plugin)); controller.handle(messages.DiscordUnlinkRequest, plugin.handleUnlinkRequest.bind(plugin)); controller.handle(messages.DiscordLinkGetRequest, plugin.handleLinkGetRequest.bind(plugin)); + controller.handle(messages.DiscordLinkStartRequest, plugin.handleLinkStartRequest.bind(plugin)); plugin.addRoutes(); plugin.cleanupInterval = setInterval(() => plugin.removeExpired(), 60e3); @@ -105,9 +111,11 @@ export class ControllerPlugin { this.sessions.delete(id); } } - for (const [code, entry] of this.codes) { - if (entry.expiresMs < now) { - this.codes.delete(code); + for (const map of [this.codes, this.linkTickets, this.pendingLinks]) { + for (const [key, entry] of map) { + if (entry.expiresMs < now) { + map.delete(key); + } } } } @@ -133,7 +141,7 @@ export class ControllerPlugin { } ); - app.get("/api/exp_discord/authorize", route(async () => this.authorize())); + app.get("/api/exp_discord/authorize", route(async req => this.authorize(req.query.link))); app.get("/api/exp_discord/callback", route(async req => this.callback( req.query, readCookie(req.headers.cookie, "exp_discord_state"), ))); @@ -152,19 +160,36 @@ export class ControllerPlugin { } /** Back to the web ui, with the outcome in the fragment so it never reaches a server log. */ - webRedirect(key: "session" | "error", value: string): RouteResult { - const url = new URL(this.controller.getControllerUrl()); + webRedirect(key: "session" | "error", value: string, page = ""): RouteResult { + const url = new URL(page, this.controller.getControllerUrl()); url.hash = `exp_discord_${key}=${encodeURIComponent(value)}`; return { redirect: url.href }; } - async authorize(): Promise { + userPage(playerName: string) { + return `users/${encodeURIComponent(playerName)}/view`; + } + + async authorize(linkTicket?: unknown): Promise { + let link: WebLink | undefined; + if (linkTicket !== undefined) { + link = typeof linkTicket === "string" ? this.linkTickets.get(linkTicket) : undefined; + if (!link || link.expiresMs < Date.now()) { + return this.webRedirect("error", "Discord link expired, please try again", "users"); + } + this.linkTickets.delete(linkTicket as string); + } + const page = link ? this.userPage(link.playerName) : ""; + const clientId = this.controller.config.get("exp_discord.client_id"); if (!clientId || !this.controller.config.get("exp_discord.client_secret")) { - return this.webRedirect("error", "Discord login is not configured on this controller"); + return this.webRedirect("error", "Discord login is not configured on this controller", page); } const state = crypto.randomBytes(16).toString("base64url"); + if (link) { + this.pendingLinks.set(state, { playerName: link.playerName, expiresMs: Date.now() + stateTimeoutS * 1e3 }); + } const url = new URL("https://discord.com/oauth2/authorize"); url.search = new URLSearchParams({ response_type: "code", @@ -172,7 +197,8 @@ export class ControllerPlugin { scope: "identify", state, redirect_uri: this.redirectUri(), - prompt: "none", + // Linking always shows the Discord consent screen, so it can't happen without the user noticing + ...(link ? {} : { prompt: "none" }), }).toString(); const callbackUrl = new URL(this.redirectUri()); @@ -184,14 +210,24 @@ export class ControllerPlugin { } async callback(query: Record, cookieState: string | undefined): Promise { - if (typeof query.error === "string") { - return this.webRedirect("error", query.error === "access_denied" ? "Discord login was cancelled" : query.error); + const link = typeof query.state === "string" ? this.pendingLinks.get(query.state) : undefined; + if (link) { + this.pendingLinks.delete(query.state as string); } - if (typeof query.state !== "string" || !cookieState || query.state !== cookieState) { - return this.webRedirect("error", "Discord login expired, please try again"); + const page = link ? this.userPage(link.playerName) : ""; + + if (typeof query.error === "string") { + const message = query.error === "access_denied" ? "Discord login was cancelled" : query.error; + return this.webRedirect("error", message, page); + } + if ( + typeof query.state !== "string" || !cookieState || query.state !== cookieState + || (link && link.expiresMs < Date.now()) + ) { + return this.webRedirect("error", "Discord login expired, please try again", page); } if (typeof query.code !== "string") { - return this.webRedirect("error", "Discord did not return a login code"); + return this.webRedirect("error", "Discord did not return a login code", page); } let discordUser; @@ -199,7 +235,16 @@ export class ControllerPlugin { discordUser = await this.fetchDiscordUser(query.code); } catch (err: any) { this.logger.warn(`Discord login failed: ${err.message}`); - return this.webRedirect("error", "Discord login failed, please try again"); + return this.webRedirect("error", "Discord login failed, please try again", page); + } + + if (link) { + try { + this.linkAccount(discordUser, link.playerName); + } catch (err: any) { + return this.webRedirect("error", err.message, page); + } + return { redirect: new URL(page, this.controller.getControllerUrl()).href }; } const account = this.accounts.getMutable(discordUser.id); @@ -351,34 +396,46 @@ export class ControllerPlugin { throw new lib.RequestError("This code is invalid or has expired, get a new one from the website"); } - const name = request.playerName.toLowerCase(); - const existing = this.accounts.get(session.id); + const linked = this.linkAccount(session, request.playerName); + this.codes.delete(code); + delete session.code; + return linked; + } + + /** Link a player who proved they own the Factorio account, it moves over from any other Discord account. */ + linkAccount(discordUser: DiscordUser, playerName: string) { + const name = playerName.toLowerCase(); + const existing = this.accounts.get(discordUser.id); const alreadyLinked = existing?.factorioNames.some(linked => linked.toLowerCase() === name); const maxAccounts = this.controller.config.get("exp_discord.max_accounts"); if (!alreadyLinked && (existing?.factorioNames.length ?? 0) >= maxAccounts) { throw new lib.RequestError( - `${session.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` + `${discordUser.name} already has ${maxAccounts} Factorio accounts linked, unlink one first` ); } - this.codes.delete(code); - delete session.code; if (!alreadyLinked) { - // The player proved they own this Factorio account, so it moves over from any other Discord account - this.removeLink(request.playerName); - const account = this.accounts.getMutable(session.id) - ?? new messages.DiscordAccountRecord(session.id, session.name, []); - account.name = session.name; - account.factorioNames = [...account.factorioNames, request.playerName]; + this.removeLink(playerName); + const account = this.accounts.getMutable(discordUser.id) + ?? new messages.DiscordAccountRecord(discordUser.id, discordUser.name, []); + account.name = discordUser.name; + account.factorioNames = [...account.factorioNames, playerName]; this.accounts.set(account); } - return { id: session.id, name: session.name }; + return { id: discordUser.id, name: discordUser.name }; } async handleUnlinkRequest(request: messages.DiscordUnlinkRequest) { return this.removeLink(request.playerName); } + /** A ticket for /api/exp_discord/authorize?link= which links the Discord account to the user. */ + async handleLinkStartRequest(request: messages.DiscordLinkStartRequest) { + const ticket = crypto.randomBytes(16).toString("base64url"); + this.linkTickets.set(ticket, { playerName: request.playerName, expiresMs: Date.now() + 60e3 }); + return ticket; + } + async handleLinkGetRequest(request: messages.DiscordLinkGetRequest) { const account = this.findAccount(request.playerName); return account ? { id: account.id, name: account.name } : null; diff --git a/exp_discord/index.ts b/exp_discord/index.ts index da0bbe2c..f247c341 100644 --- a/exp_discord/index.ts +++ b/exp_discord/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.DiscordLinkRequest, messages.DiscordUnlinkRequest, messages.DiscordLinkGetRequest, + messages.DiscordLinkStartRequest, ], permissions: [ diff --git a/exp_discord/messages.ts b/exp_discord/messages.ts index ab71d22a..4836866c 100644 --- a/exp_discord/messages.ts +++ b/exp_discord/messages.ts @@ -64,6 +64,14 @@ function selfOrPermission(permission: lib.PermissionName) { }; } +/** Only the user themselves, no permission lets someone link another user to their Discord. */ +function selfOnly(user: lib.IUser, message: lib.MessageRequest) { + const playerName = (message.data as { player_name: string }).player_name; + if (user.name.toLowerCase() !== playerName.toLowerCase()) { + throw new lib.PermissionError("You can only link your own account"); + } +} + const LinkResponse = lib.plainJson(Type.Union([ Type.Null(), Type.Object({ @@ -157,3 +165,30 @@ export class DiscordLinkGetRequest { return new this(json.player_name); } } + +/** Start linking the Discord account from the user page, responds with a ticket for the authorize route. */ +export class DiscordLinkStartRequest { + declare ["constructor"]: typeof DiscordLinkStartRequest; + static plugin = "exp_discord" as const; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = selfOnly; + static Response = lib.plainJson(Type.String()); + + constructor( + public playerName: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + }); + + toJSON() { + return { player_name: this.playerName }; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} diff --git a/exp_discord/test/controller.test.js b/exp_discord/test/controller.test.js index b06869f3..707eaf32 100644 --- a/exp_discord/test/controller.test.js +++ b/exp_discord/test/controller.test.js @@ -217,6 +217,60 @@ t.test("linking", t2 => { t2.end(); }); +t.test("linking from the user page", t2 => { + /** Start a link for playerName and follow authorize, returning the OAuth state. */ + async function start(plugin, playerName) { + const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName)); + const result = await plugin.authorize(ticket); + return new URL(result.redirect).searchParams; + } + + t2.test("links the Discord account and goes back to the user page", async t3 => { + const { plugin } = await startPlugin(t3); + const params = await start(plugin, "Player1"); + t3.equal(params.get("prompt"), null, "consent screen is shown"); + plugin.fetchDiscordUser = async () => alice; + const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state")); + t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view"); + t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]); + t3.equal(plugin.sessions.size, 0, "no login session is started"); + t3.equal(plugin.pendingLinks.size, 0); + }); + + t2.test("a ticket can only be used once", async t3 => { + const { plugin } = await startPlugin(t3); + const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1")); + await plugin.authorize(ticket); + const result = await plugin.authorize(ticket); + t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_error=Discord%20link%20expired/); + }); + + t2.test("errors go back to the user page", async t3 => { + const { plugin } = await startPlugin(t3, { config: { "exp_discord.max_accounts": 1 } }); + await link(plugin, await discordLogin(plugin, alice), "Player2"); + + let params = await start(plugin, "Player1"); + let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state")); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=Discord%20login%20was%20cancelled$/); + + params = await start(plugin, "Player1"); + plugin.fetchDiscordUser = async () => alice; + result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state")); + t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_error=.*already%20has%201/); + t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]); + }); + + t2.test("a state without the cookie does not link", async t3 => { + const { plugin } = await startPlugin(t3); + const params = await start(plugin, "Player1"); + plugin.fetchDiscordUser = async () => t3.fail("should not fetch"); + const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined); + t3.match(result.redirect, "/users/Player1/view#exp_discord_error="); + t3.notOk(plugin.accounts.has(alice.id)); + }); + t2.end(); +}); + t.test("permissions", t2 => { const user = (name, granted = []) => ({ name, @@ -228,6 +282,15 @@ t.test("permissions", t2 => { }); const request = name => ({ data: { player_name: name } }); + t2.test("DiscordLinkStartRequest", async t3 => { + const permission = messages.DiscordLinkStartRequest.permission; + t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account"); + t3.throws( + () => permission(user("Alice", ["exp_discord.link.get", "exp_discord.link.delete"]), request("Bob")), + "never someone else's", + ); + }); + for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) { t2.test(Request.name, async t3 => { const permission = Request === messages.DiscordUnlinkRequest diff --git a/exp_discord/web/components/UserDiscordLink.tsx b/exp_discord/web/components/UserDiscordLink.tsx index afb0d4a3..47ea1249 100644 --- a/exp_discord/web/components/UserDiscordLink.tsx +++ b/exp_discord/web/components/UserDiscordLink.tsx @@ -1,14 +1,25 @@ import React, { useContext, useEffect, useState } from "react"; -import { Button, Descriptions, Popconfirm } from "antd"; +import { Alert, Button, Descriptions, Popconfirm } from "antd"; import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount, } from "@clusterio/web_ui"; -import { DiscordLinkGetRequest, DiscordUnlinkRequest } from "../../messages.js"; +import { DiscordLinkGetRequest, DiscordLinkStartRequest, DiscordUnlinkRequest } from "../../messages.js"; + +declare const webRoot: string; type Link = { id: string, name: string } | null; +/** An error from linking on Discord, taken out of the url so a reload does not show it again. */ +function readError() { + const error = new URLSearchParams(window.location.hash.slice(1)).get("exp_discord_error"); + if (error) { + window.history.replaceState(null, "", window.location.pathname + window.location.search); + } + return error; +} + /** The Discord account a user is linked to, shown on their user page. */ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPage">) { const control = useContext(ControlContext); @@ -17,6 +28,7 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag const canGet = own || account.hasPermission("exp_discord.link.get"); const canDelete = own || account.hasPermission("exp_discord.link.delete"); const [link, setLink] = useState(undefined); + const [error, setError] = useState(readError); useEffect(() => { if (!canGet) { @@ -36,8 +48,21 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag setLink(null); } + async function startLink() { + const ticket = await control.send(new DiscordLinkStartRequest(props.user.name)); + window.location.href = `${webRoot}api/exp_discord/authorize?link=${encodeURIComponent(ticket)}`; + } + return <> + {error && setError(null)} + style={{ marginBottom: 16 }} + />} {link ? <> @@ -48,7 +73,14 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag > } - : "Not linked"} + : <> + Not linked + {own && } + } ;