Skip to content

Instantly share code, notes, and snippets.

@promise
Last active July 3, 2023 10:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save promise/951880b7ee092aedaa5d6f281f181046 to your computer and use it in GitHub Desktop.
Save promise/951880b7ee092aedaa5d6f281f181046 to your computer and use it in GitHub Desktop.
An OAuth2 middleware for Dragory's Modmail bot

Dragory Modmail OAuth Integration (+ confidential logs)

Add an OAuth2 middleware to verify the user has access to the inbox server.

Setup

Copy the files into your modmail project. Location of the files are in the comments on all the files.

Configure it in the webserverPlugin.js-file.

What you need to configure yourself is

  • a bot secret, grab this from discord.dev
  • a different redirect URI + path if needed (also make sure this redirect URI is configured on the bot application
    • if your website is hosted at https://example.com and your path is /auth then your redirect URI would be https://example.com/auth

Other variables are optional, mostly self-explaining. (Confidential mode is explained further down)

Allow confidential logs

Basically what it does is allow you to mark threads as confidential and not be viewed normally by staff members. Allow certain roles' members to view certain logs.

You can mark threads as confidential with the commands !promote and !demote in a thread. (these can be customized)

Why?

A server I moderate in (using modmail obviously) has a problem where when sensitive information is disclosed in a modmail, e.g. giveaway keys or even a staff complaint, then there's no way for admins to prevent other staff members to view this specific log. We usually move the thread to a category they don't have access to, but there's no way for us to directly prevent them from viewing the log unless we take the log offline.

Setup

Configure it in the webserverPlugin.js-file. Configuration should be self-explaining.

No help is guaranteed for this. You get this code as-is, and hope the README is self-explanatory to its purpose.

// location: src/modules/webserver.js
// gist: https://gist.github.com/promise/951880b7ee092aedaa5d6f281f181046#file-webserver-js
const express = require("express");
const helmet = require("helmet");
const server = express();
server.use(helmet());
server.on("error", err => console.log("[WARN] Web server error:", err.message));
module.exports = server;
// location: src/modules/webserverPlugin.js
// gist: https://gist.github.com/promise/951880b7ee092aedaa5d6f281f181046#file-webserverplugin-js
const server = require("./webserver");
const express = require("express");
const mime = require("mime");
const fs = require("fs");
const config = require("../cfg");
const threads = require("../data/threads");
const attachments = require("../data/attachments");
const { formatters } = require("../formatters");
const crypto = require("crypto");
const bot = require("../bot");
const https = require("https");
const utils = require("../utils");
// CONFIG
// CONFIGURE OAUTH HERE
const botClientSecret = "6VZuQNWz-qaEn5o8hqgCaBAKl3V1yIlu";
const oauthPath = "/auth";
const oauthRedirectUri = config.url + oauthPath;
const tokenExpirationInSeconds = 3600; // 1 hour (recommended) - they'll be redirected to the oauth page if it has expired
const encryptionKey = crypto.randomBytes(64).toString("base64");
const attachmentsRequireOauth = true;
const usersNotRequiredToBeInInboxServer = [];
// CONFIGURE CONFIDENTIALITY HERE
const allowMarkingThreadAsConfidential = false; // enable this if you want this module to work
const rolesWithConfidentialAccess = [];
const usersWithConfidentialAccess = [];
const confidentialAccessToAllAdminsInInboxServer = true;
const markThreadAsConfidentialCommands = [ "promote", "elevate", "raise" ];
const markThreadAsConfidentialMessage = "✅ **This thread is now marked as confidential.** Log access has been restricted to those with confidential access.";
const threadAlreadyConfidentialMessage = "⚠️ This thread is already marked as confidential.";
const unmarkThreadAsConfidentialCommands = [ "demote", "downgrade", "lower" ];
const unmarkThreadAsConfidentialMessage = "✅ **This thread is no longer marked as confidential.**";
const threadNotConfidentialMessage = "⚠️ This thread is not confidential.";
// END CONFIG
module.exports = ({ config, commands }) => {
server.get("/logs/:threadId", ...[
testTokenCookie,
testUserGeneralAccess,
testUserConfidentialAccess,
serveLogs
]);
server.get("/attachments/:attachmentId/:filename", ...[
...(attachmentsRequireOauth ? [
testTokenCookie,
testUserGeneralAccess,
] : []),
serveAttachments
]);
server.get(oauthPath, processAuth)
server.listen(config.port);
if (allowMarkingThreadAsConfidential) {
commands.addInboxServerCommand(markThreadAsConfidentialCommands.shift(), "[threadId:string]", async (msg, args, _thread) => {
const threadId = args.threadId || (_thread && _thread.id);
if (! threadId) return;
const thread = (await threads.findById(threadId) || (await threads.findByThreadNumber(threadId)));
if (!thread) return;
const confidential = thread.getMetadataValue("confidential");
if (confidential) return utils.postSystemMessageWithFallback(msg.channel, _thread, threadAlreadyConfidentialMessage);
thread.setMetadataValue("confidential", true);
utils.postSystemMessageWithFallback(msg.channel, _thread, markThreadAsConfidentialMessage);
}, { aliases: markThreadAsConfidentialCommands });
commands.addInboxServerCommand(unmarkThreadAsConfidentialCommands.shift(), "[threadId:string]", async (msg, args, _thread) => {
const threadId = args.threadId || (_thread && _thread.id);
if (! threadId) return;
const thread = (await threads.findById(threadId) || (await threads.findByThreadNumber(threadId)));
if (!thread) return;
const confidential = thread.getMetadataValue("confidential");
if (!confidential) return utils.postSystemMessageWithFallback(msg.channel, _thread, threadNotConfidentialMessage);
thread.setMetadataValue("confidential", false);
utils.postSystemMessageWithFallback(msg.channel, _thread, unmarkThreadAsConfidentialMessage);
}, { aliases: unmarkThreadAsConfidentialCommands });
}
};
function badRequest(res) {
res.sendStatus(400);
}
function forbidden(res, reason) {
res.status(403).send("Forbidden" + (reason ? ": " + reason : ""));
}
function notFound(res) {
res.sendStatus(404);
}
function goToOauth(res, path) {
const url = new URL("https://discord.com/api/oauth2/authorize");
url.searchParams.set("client_id", bot.user.id);
url.searchParams.set("redirect_uri", oauthRedirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "identify");
url.searchParams.set("state", path);
res.redirect(url.toString());
}
/**
* @param {express.Request} req
* @param {express.Response} res
*/
async function serveLogs(req, res) {
const thread = await threads.findById(req.params.threadId);
if (! thread) return notFound(res);
let threadMessages = await thread.getThreadMessages();
const formatLogResult = await formatters.formatLog(thread, threadMessages, {
simple: Boolean(req.query.simple),
verbose: Boolean(req.query.verbose),
});
const contentType = formatLogResult.extra && formatLogResult.extra.contentType || "text/plain; charset=UTF-8";
res.set("Content-Type", contentType);
res.send(formatLogResult.content);
}
/**
* @param {express.Request} req
* @param {express.Response} res
*/
function serveAttachments(req, res) {
if (req.params.attachmentId.match(/^[0-9]+$/) === null) return notFound(res);
if (req.params.filename.match(/^[0-9a-z._-]+$/i) === null) return notFound(res);
const attachmentPath = attachments.getLocalAttachmentPath(req.params.attachmentId);
fs.access(attachmentPath, (err) => {
if (err) return notFound(res);
const filenameParts = req.params.filename.split(".");
const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : "bin");
const fileMime = mime.getType(ext);
res.set("Content-Type", fileMime);
const read = fs.createReadStream(attachmentPath);
read.pipe(res);
})
}
/**
* @param {express.Request} req
* @param {express.Response} res
*/
async function processAuth(req, res) {
const path = req.query.state;
if (! path) return badRequest(res);
const code = req.query.code;
if (! code) return goToOauth(res, path);
const accessToken = await getAccessToken(code);
if (! accessToken) return goToOauth(res, path);
const userId = await getUserIdFromAccessToken(accessToken);
if (! userId) return goToOauth(res, path);
const jwt = createToken({ userId });
res.cookie("token", jwt);
res.redirect(path);
}
// middlewares
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
function testTokenCookie(req, res, next) {
// get token from raw cookies
const cookiePair = (req.headers.cookie || "").split(";").map(s => s.trim()).find(s => s.startsWith("token="));
const token = cookiePair && cookiePair.split("=")[1];
if (! token) return goToOauth(res, req.path);
// validate token
const decoded = decodeToken(token);
if (! decoded) return goToOauth(res, req.path);
// if expired, reauthorize
if (decoded.expireAt < Date.now()) return goToOauth(res, req.path);
// if not expired, set userId and go next
req.userId = decoded.userId;
next();
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
function testUserGeneralAccess(req, res, next) {
const { userId } = req;
if (usersNotRequiredToBeInInboxServer.includes(userId)) return next();
bot.getRESTGuildMember(config.inboxServerId, userId).then(member => {
req.inboxMember = member;
next();
}).catch(() => forbidden(res, "Not in inbox server"))
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async function testUserConfidentialAccess(req, res, next) {
if (! allowMarkingThreadAsConfidential) return next();
const thread = await threads.findById(req.path.split("/").pop());
if (! thread) return badRequest(res);
const confidential = thread.getMetadataValue("confidential");
const { inboxMember, userId } = req;
if (
!confidential ||
(inboxMember &&
(confidentialAccessToAllAdminsInInboxServer && (inboxMember.permissions.allow & 8n) === 8n) ||
inboxMember.roles.some(r => rolesWithConfidentialAccess.includes(r))
) ||
usersWithConfidentialAccess.includes(userId)
) return next();
forbidden(res, "User has no confidential access (thread marked as confidential)")
}
// discord oauth functions
function getAccessToken(code) {
return new Promise(resolve => {
const params = new URLSearchParams();
params.set("client_id", bot.user.id);
params.set("client_secret", botClientSecret);
params.set("grant_type", "authorization_code");
params.set("code", code);
params.set("redirect_uri", oauthRedirectUri);
https.request({
method: "POST",
host: "discord.com",
path: "/api/oauth2/token",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}, response => {
let body = "";
response.on("data", (chunk) => body += chunk);
response.on("end", () => resolve(JSON.parse(body).access_token));
}).end(params.toString());
})
}
function getUserIdFromAccessToken(accessToken) {
return new Promise(resolve => {
https.request({
method: "GET",
host: "discord.com",
path: "/api/oauth2/@me",
headers: {
"Authorization": `Bearer ${accessToken}`,
},
}, response => {
let body = "";
response.on("data", (chunk) => body += chunk);
response.on("end", () => resolve((JSON.parse(body).user || {}).id));
}).end();
})
}
// JWT functions ~~that I stole~~ from https://github.com/hokaccha/node-jwt-simple/blob/master/lib/jwt.js
function createToken(payload) {
payload.expireAt = Date.now() + tokenExpirationInSeconds * 1000;
const segments = [];
segments.push(base64urlEncode(JSON.stringify({ typ: "JWT", alg: "HS256" })));
segments.push(base64urlEncode(JSON.stringify(payload)));
segments.push(sign(segments.join(".")));
return segments.join(".");
}
function decodeToken(token) {
const segments = token.split(".");
if (segments.length !== 3) return null;
const [headerSeg, payloadSeg, signatureSeg] = segments;
if (signatureSeg !== sign(`${headerSeg}.${payloadSeg}`)) return null;
return JSON.parse(base64urlDecode(payloadSeg));
}
function sign(input) {
return base64urlEscape(crypto.createHmac("sha256", encryptionKey).update(input).digest("base64"));
}
function base64urlDecode(str) {
return Buffer.from(base64urlUnescape(str), "base64").toString();
}
function base64urlUnescape(str) {
return (str + "=".repeat(4 - str.length % 4)).replace(/-/g, "+").replace(/_/g, "/");
}
function base64urlEncode(str) {
return base64urlEscape(Buffer.from(str).toString("base64"));
}
function base64urlEscape(str) {
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment