feat: oauth flow
Node.js CI / Lint and Test (push) Successful in 23s

This commit is contained in:
2025-12-03 14:23:26 -08:00
parent ede03ca8e8
commit 262cdcb702
2 changed files with 281 additions and 38 deletions
+2
View File
@@ -1,2 +1,4 @@
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key" AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
+274 -33
View File
@@ -3,9 +3,22 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable complexity, max-lines-per-function -- This is a chonky boi script. */ /* eslint-disable complexity, max-lines-per-function, max-lines, max-statements -- This is a chonky boi script. */
import { password, confirm } from "@inquirer/prompts"; /**
* OAuth setup (do this once per local machine):
* 1. Create a Discord application in the Developer Portal and note the Client ID.
* 2. Under OAuth2 → Redirects, add http://127.0.0.1:8721/callback (or supply your own via DISCORD_REDIRECT_URI).
* 3. (Optional but recommended) Generate a Client Secret and store it in DISCORD_CLIENT_SECRET.
* 4. Export DISCORD_CLIENT_ID (and secret if used) in your shell env before running this script.
* 5. Run the script; it will print an authorization URL. Approve the request in your browser and the local OAuth callback will handle the rest.
* Using OAuth this way keeps the flow within Discords ToS—no user tokens are ever collected or stored.
*/
import crypto from "node:crypto";
import http from "node:http";
import process from "node:process";
import { confirm } from "@inquirer/prompts";
interface Guild { interface Guild {
name?: string; name?: string;
@@ -121,45 +134,272 @@ const analyzeGuilds = (guilds: Array<Guild>): void => {
printReport(stats); printReport(stats);
}; };
/** const defaultRedirectUri = "http://127.0.0.1:8721/callback";
* Fetches the user's guilds and analyses them. const defaultScopes = "identify guilds";
* For safety, we require the user to confirm our terms to continue. const authorizeEndpoint = "https://discord.com/oauth2/authorize";
* Token is provided as an obscured password input to avoid accidental disclosure. const tokenEndpoint = "https://discord.com/api/v10/oauth2/token";
*/
async function getGuilds(): Promise<void> { const isRecord = (value: unknown): value is Record<PropertyKey, unknown> => {
console.log( return typeof value === "object" && value !== null;
`WARNING! This script requires your user token. Because of this, you MUST take these into consideration:`, };
const base64UrlEncode = (buffer: Buffer): string => {
const base64 = buffer.toString("base64");
return base64.replaceAll("+", "-").replaceAll("/", "_").
replace(/[=]+$/u, "");
};
const generateCodeVerifier = (): string => {
return base64UrlEncode(crypto.randomBytes(64));
};
const generateCodeChallenge = (codeVerifier: string): string => {
const hash = crypto.createHash("sha256").update(codeVerifier).
digest();
return base64UrlEncode(hash);
};
async function waitForOAuthCode(
redirectUri: string,
expectedState: string,
): Promise<string> {
const { hostname, port, pathname, protocol } = new URL(redirectUri);
if (protocol !== "http:") {
throw new Error("Only HTTP redirect URIs are supported for local OAuth.");
}
let listenPort = 80;
if (port !== "") {
listenPort = Number.parseInt(port, 10);
}
let listenHost: string | undefined = hostname;
let displayHost = hostname;
if (hostname === "") {
listenHost = undefined;
displayHost = "localhost";
}
return await new Promise((resolve, reject) => {
const server = http.createServer();
const timeout = setTimeout(() => {
server.close();
reject(new Error("OAuth approval timed out. Please try again."));
}, 5 * 60 * 1000);
const finish = (
result: "resolve" | "reject",
value?: string | Error,
): void => {
clearTimeout(timeout);
server.close();
if (result === "resolve" && typeof value === "string") {
resolve(value);
return;
}
if (result === "reject" && value instanceof Error) {
reject(value);
}
};
const sendPlainText = (
response: http.ServerResponse,
status: number,
message: string,
): void => {
response.statusCode = status;
response.setHeader("Content-Type", "text/plain");
response.end(message);
};
function handleOAuthRequest(
request: http.IncomingMessage,
response: http.ServerResponse,
): void {
if (request.method !== "GET" || request.url === undefined) {
sendPlainText(response, 405, "Method Not Allowed");
return;
}
const requestUrl = new URL(request.url, redirectUri);
if (requestUrl.pathname !== pathname) {
sendPlainText(response, 404, "Not Found");
return;
}
const incomingState = requestUrl.searchParams.get("state");
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code");
if (error !== null) {
sendPlainText(response, 400, `OAuth Error: ${error}`);
finish("reject", new Error(`OAuth error: ${error}`));
return;
}
if (incomingState !== expectedState || code === null) {
sendPlainText(response, 400, "Invalid OAuth response.");
finish("reject", new Error("OAuth state mismatch or missing code."));
return;
}
sendPlainText(
response,
200,
"Authorization received. You can close this tab.",
); );
finish("resolve", code);
}
server.on("request", handleOAuthRequest);
server.listen(listenPort, listenHost, () => {
console.log( console.log(
`1. DO NOT SHARE YOUR TOKEN WITH ANYONE. Your token can be used to impersonate your account, and can only be changed by rotating your account password.`, `Waiting for OAuth callback on http://${displayHost}:${listenPort.toString()}${pathname}`,
); );
console.log(
`2. THIS SCRIPT IS CONSIDERED SELF BOTTING. Running this is a violation of Discord's Terms of Service. DO SO AT YOUR OWN RISK!`,
);
console.log(
`3. Naomi Carrigan, NHCarrigan, its associates, and its affiliates are not responsible for any actions taken by you using this script.`,
);
const confirmed = await confirm({
message:
"I understand these risks, agree to the terms, and want to continue.",
}); });
if (!confirmed) { });
throw new Error("User did not confirm the terms.");
} }
const token = await password({
mask: true, interface TokenExchangeOptions {
message: "Please enter your user token:", clientId: string;
validate: (value) => { clientSecret?: string;
if (value === "") { code: string;
return "Token is required"; codeVerifier: string;
redirectUri: string;
} }
return true;
}, interface TokenExchangeOptions {
clientId: string;
clientSecret?: string | undefined;
code: string;
codeVerifier: string;
redirectUri: string;
}
async function exchangeCodeForToken({
clientId,
clientSecret,
code,
codeVerifier,
redirectUri,
}: TokenExchangeOptions): Promise<string> {
const body = new URLSearchParams();
body.set("client_id", clientId);
body.set("code", code);
body.set("code_verifier", codeVerifier);
body.set("grant_type", "authorization_code");
body.set("redirect_uri", redirectUri);
if (clientSecret !== undefined && clientSecret !== "") {
body.set("client_secret", clientSecret);
}
const headers = new Headers();
headers.set("content-type", "application/x-www-form-urlencoded");
const tokenResponse = await fetch(tokenEndpoint, {
body: body,
headers: headers,
method: "POST",
}); });
if (token === "") { if (!tokenResponse.ok) {
throw new Error("Missing TOKEN"); throw new Error(
`OAuth token exchange failed with status ${tokenResponse.status.toString()}`,
);
} }
const tokenPayload: unknown = await tokenResponse.json();
if (!isRecord(tokenPayload)) {
throw new TypeError("Token payload is not an object.");
}
const accessToken = tokenPayload.access_token;
if (typeof accessToken !== "string") {
throw new TypeError("No access token returned from Discord.");
}
return accessToken;
}
async function startOAuthFlow(): Promise<string> {
const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI ?? defaultRedirectUri;
const scopes = process.env.DISCORD_SCOPES ?? defaultScopes;
if (clientId === undefined || clientId === "") {
throw new Error(
`Could not find Discord client ID. Please ensure you have followed the steps outlined when you ran this script.`,
);
}
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
const authUrl = new URL(authorizeEndpoint);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", scopes);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("prompt", "consent");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
console.log("\n====== Discord OAuth ======");
console.log("1. Open the following URL in your browser:");
console.log(authUrl.toString());
console.log("2. Approve access for your application.");
console.log(
"3. Return here; the script will continue once approval is complete.\n",
);
const code = await waitForOAuthCode(redirectUri, state);
const tokenRequest: TokenExchangeOptions = {
clientId,
code,
codeVerifier,
redirectUri,
};
if (clientSecret !== undefined && clientSecret !== "") {
tokenRequest.clientSecret = clientSecret;
}
return await exchangeCodeForToken(tokenRequest);
}
/**
* Fetches the user's guilds and analyses them (via OAuth and PKCE).
*/
async function getGuilds(): Promise<void> {
console.log("In order to run this script, you must complete a few steps.");
console.log(
`1. Create a Discord application in the Developer Portal and note the Client ID.`,
);
console.log(
`2. Under OAuth2 → Redirects, add http://127.0.0.1:8721/callback (or supply your own via DISCORD_REDIRECT_URI).`,
);
console.log(
`3. (Optional but recommended) Generate a Client Secret and store it in DISCORD_CLIENT_SECRET.`,
);
console.log(
`4. Export DISCORD_CLIENT_ID (and secret if used) in your shell env before running this script.`,
);
console.log(
`5. Run the script; it will print an authorization URL. Approve the request in your browser and the local OAuth callback will handle the rest.`,
);
console.log(
`Using OAuth this way keeps the flow within Discords ToS—no user tokens are ever collected or stored.`,
);
const confirmed = await confirm({
message: "Have you completed these steps already?",
});
if (!confirmed) {
console.log("Please complete the steps and try again.");
return;
}
console.log(
"Starting OAuth flow to fetch joined servers without exposing user tokens.",
);
const accessToken = await startOAuthFlow();
console.log("Fetching servers..."); console.log("Fetching servers...");
// Discord allows max 200 servers per user (with Nitro), so limit=200 catches all. // Discord allows max 200 servers per user (with Nitro), so limit=200 catches all.
@@ -167,7 +407,7 @@ async function getGuilds(): Promise<void> {
"https://discord.com/api/v10/users/@me/guilds?limit=200", "https://discord.com/api/v10/users/@me/guilds?limit=200",
{ {
headers: { headers: {
authorization: token, authorization: `Bearer ${accessToken}`,
}, },
}, },
); );
@@ -186,5 +426,6 @@ async function getGuilds(): Promise<void> {
} }
await getGuilds(); await getGuilds();
process.exit(0);
export { getGuilds }; export { getGuilds };