From 262cdcb702850edb3292f192cbb92dbc99bd003e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 3 Dec 2025 14:23:26 -0800 Subject: [PATCH] feat: oauth flow --- prod.env | 4 +- src/discord/guildCount.ts | 315 +++++++++++++++++++++++++++++++++----- 2 files changed, 281 insertions(+), 38 deletions(-) diff --git a/prod.env b/prod.env index 2e39492..099074f 100644 --- a/prod.env +++ b/prod.env @@ -1,2 +1,4 @@ AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" -AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key" \ No newline at end of file +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" \ No newline at end of file diff --git a/src/discord/guildCount.ts b/src/discord/guildCount.ts index 6af37aa..7e5dd73 100644 --- a/src/discord/guildCount.ts +++ b/src/discord/guildCount.ts @@ -3,9 +3,22 @@ * @license Naomi's Public License * @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 Discord’s 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 { name?: string; @@ -121,45 +134,272 @@ const analyzeGuilds = (guilds: Array): void => { printReport(stats); }; -/** - * Fetches the user's guilds and analyses them. - * For safety, we require the user to confirm our terms to continue. - * Token is provided as an obscured password input to avoid accidental disclosure. - */ -async function getGuilds(): Promise { - console.log( - `WARNING! This script requires your user token. Because of this, you MUST take these into consideration:`, - ); - 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.`, - ); - 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 defaultRedirectUri = "http://127.0.0.1:8721/callback"; +const defaultScopes = "identify guilds"; +const authorizeEndpoint = "https://discord.com/oauth2/authorize"; +const tokenEndpoint = "https://discord.com/api/v10/oauth2/token"; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +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 { + const { hostname, port, pathname, protocol } = new URL(redirectUri); + if (protocol !== "http:") { + throw new Error("Only HTTP redirect URIs are supported for local OAuth."); } - const token = await password({ - mask: true, - message: "Please enter your user token:", - validate: (value) => { - if (value === "") { - return "Token is required"; + 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; } - return true; - }, + 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( + `Waiting for OAuth callback on http://${displayHost}:${listenPort.toString()}${pathname}`, + ); + }); + }); +} + +interface TokenExchangeOptions { + clientId: string; + clientSecret?: string; + code: string; + codeVerifier: string; + redirectUri: string; +} + +interface TokenExchangeOptions { + clientId: string; + clientSecret?: string | undefined; + code: string; + codeVerifier: string; + redirectUri: string; +} + +async function exchangeCodeForToken({ + clientId, + clientSecret, + code, + codeVerifier, + redirectUri, +}: TokenExchangeOptions): Promise { + 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 === "") { - throw new Error("Missing TOKEN"); + if (!tokenResponse.ok) { + 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 { + 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 { + 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 Discord’s 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..."); // Discord allows max 200 servers per user (with Nitro), so limit=200 catches all. @@ -167,7 +407,7 @@ async function getGuilds(): Promise { "https://discord.com/api/v10/users/@me/guilds?limit=200", { headers: { - authorization: token, + authorization: `Bearer ${accessToken}`, }, }, ); @@ -186,5 +426,6 @@ async function getGuilds(): Promise { } await getGuilds(); +process.exit(0); export { getGuilds };