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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-14 13:34:14 +00:00
1 parent 8e9f780cc2
commit e4fda18276
6 files changed
+261 -30

No files matched your search

+43
View File
@@ -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 `<controller.public_url>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 <client id>
npx clusterioctl controller config set exp_discord.client_secret <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.
+84 -27
View File
@@ -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<string, Session>();
/** Codes waiting to be used in game, by code */
codes = new Map<string, LinkCode>();
/** Links started from the user page, by ticket until authorize turns them into a state */
linkTickets = new Map<string, WebLink>();
/** Links waiting for the Discord callback, by OAuth state */
pendingLinks = new Map<string, WebLink>();
cleanupInterval?: ReturnType<typeof setInterval>;
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<RouteResult> {
userPage(playerName: string) {
return `users/${encodeURIComponent(playerName)}/view`;
}
async authorize(linkTicket?: unknown): Promise<RouteResult> {
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<string, unknown>, cookieState: string | undefined): Promise<RouteResult> {
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;
+1
View File
@@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = {
messages.DiscordLinkRequest,
messages.DiscordUnlinkRequest,
messages.DiscordLinkGetRequest,
messages.DiscordLinkStartRequest,
],
permissions: [
+35
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.player_name);
}
}
+63
View File
@@ -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
+35 -3
View File
@@ -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<Link | undefined>(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 <>
<SectionHeader title="Discord" />
{error && <Alert
type="error"
message={error}
showIcon
closable
onClose={() => setError(null)}
style={{ marginBottom: 16 }}
/>}
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Linked account">
{link ? <>
@@ -48,7 +73,14 @@ export default function UserDiscordLink(props: PluginExtensionProps<"UserViewPag
>
<Button danger size="small" style={{ marginLeft: 8 }}>Unlink</Button>
</Popconfirm>}
</> : "Not linked"}
</> : <>
Not linked
{own && <Button
size="small"
style={{ marginLeft: 8 }}
onClick={() => { startLink().catch(notifyErrorHandler("Error linking Discord")); }}
>Link Discord</Button>}
</>}
</Descriptions.Item>
</Descriptions>
</>;