Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
86ad3a4
feat: add guild admin authentication and authorization
BillChirico Feb 17, 2026
38feff0
fix: address PR #73 review feedback
BillChirico Feb 17, 2026
8114c01
fix: move Discord access token from JWT to server-side session store
BillChirico Feb 17, 2026
0657948
fix: add interval-based cleanup for OAuth state map
BillChirico Feb 17, 2026
e037302
fix: cache guild fetches to avoid Discord API call on every request
BillChirico Feb 17, 2026
2ea7ea4
fix: address PR #73 review comments
BillChirico Feb 17, 2026
fe7a652
fix: consolidate auth cleanup intervals
BillChirico Feb 17, 2026
6f31174
fix: add algorithm HS256 to all jwt.sign calls in tests
BillChirico Feb 17, 2026
d287621
fix: use static import for jsonwebtoken in auth middleware test
BillChirico Feb 17, 2026
5c1dafe
fix: add ip and path to mock req in oauth middleware test
BillChirico Feb 17, 2026
51fc321
fix: add cleanup method to SessionStore and remove direct entry itera…
BillChirico Feb 17, 2026
d9dd49e
fix: use nullish coalescing for owners fallback in permissions
BillChirico Feb 17, 2026
0b41aaa
fix: add periodic cleanup for expired guildCache entries
BillChirico Feb 17, 2026
8ba3f53
fix: correct OAuth redirect URI in .env.example
BillChirico Feb 17, 2026
81f238f
refactor: extract shared JWT verification helper
BillChirico Feb 17, 2026
22ddbc4
refactor: extract fetchUserGuilds to shared utility module
BillChirico Feb 17, 2026
bf9a30e
fix: prevent TypeError if bot leaves guild during list operation
BillChirico Feb 17, 2026
32a8084
test: clear shared guild cache in auth route tests
BillChirico Feb 17, 2026
9058e80
test: add happy-path OAuth2 callback test with state seeding helper
BillChirico Feb 17, 2026
82b4a8a
fix: address remaining PR #73 review comments
BillChirico Feb 17, 2026
8ae3cfc
refactor: export shared Discord API base URL constant
BillChirico Feb 17, 2026
dc6da3e
refactor: consume shared Discord API constant in auth routes
BillChirico Feb 17, 2026
4927aa4
fix: validate Discord OAuth callback payloads
BillChirico Feb 17, 2026
c1b9ae1
test: cover OAuth callback failure paths
BillChirico Feb 17, 2026
0687145
fix: require explicit bot owner configuration
BillChirico Feb 17, 2026
733b6e6
fix: add requireGuildAdmin to guild info endpoint, fix OAuth state ex…
BillChirico Feb 17, 2026
7892460
refactor: delegate SessionStore.has() to get() to avoid duplicated TT…
BillChirico Feb 17, 2026
74cebb2
fix: add explicit NaN guard for guild permissions in isOAuthGuildAdmin
BillChirico Feb 17, 2026
61c1e81
fix: stop leaking Discord API error status in thrown error messages
BillChirico Feb 17, 2026
efe7303
fix: log guild-fetch failures in /me endpoint instead of silent catch
BillChirico Feb 17, 2026
e8e3b10
refactor: remove unreachable null guard after filter in guild list en…
BillChirico Feb 17, 2026
b375d6b
fix: run requireGuildAdmin before validateGuild to prevent guild-pres…
BillChirico Feb 17, 2026
5c54a4a
fix: validate Discord guild response is an array before caching
BillChirico Feb 17, 2026
c51105b
refactor: use mocked PermissionFlagsBits.ManageGuild constant in test
BillChirico Feb 17, 2026
4db3706
fix: set req.authMethod in requireOAuth middleware
BillChirico Feb 17, 2026
15ca9f7
fix: address unresolved review comments on PR #73
BillChirico Feb 17, 2026
66f9f97
fix: address remaining review warnings for guild admin auth
BillChirico Feb 17, 2026
ef62f98
fix: address all 24 unresolved PR #73 review threads
BillChirico Feb 17, 2026
33591fd
fix: address all 25 unresolved PR #73 review threads
BillChirico Feb 17, 2026
92ff4fb
fix: preserve moderator role fallback in isModerator
BillChirico Feb 17, 2026
29a9af9
fix(api): include userId in guild permission error log
BillChirico Feb 17, 2026
958487f
refactor(errors): centralize DiscordApiError in shared module
BillChirico Feb 17, 2026
a610284
docs(config): document moderatorRoleId permission setting
BillChirico Feb 17, 2026
68f688a
test(auth): remove unnecessary async from sync cases
BillChirico Feb 17, 2026
a22697d
test(oauth): assert authMethod is set for valid jwt
BillChirico Feb 17, 2026
519a05c
fix: honor permission toggles in config and modlog commands
BillChirico Feb 17, 2026
8fd3516
refactor: share oauth jwt middleware verification flow
BillChirico Feb 17, 2026
1df4565
fix: add oauth bot-owner bypass and guild access metadata
BillChirico Feb 17, 2026
160d4f6
fix: hard-cap oauth guild cache under burst inserts
BillChirico Feb 17, 2026
038e0e1
test: simplify config command permission mocks
BillChirico Feb 17, 2026
caa09b5
test: simplify modlog mocks and fix missing-config setup
BillChirico Feb 17, 2026
a77f0c6
refactor: route admin permission checks through isGuildAdmin
BillChirico Feb 17, 2026
e9737f5
docs: clarify modlog default moderator permission
BillChirico Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"permissions": {
"enabled": true,
"adminRoleId": null,
"botOwners": ["191633014441115648"],
"usePermissions": true,
"allowedCommands": {
"ping": "everyone",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mem0ai": "^2.2.2",
"pg": "^8.18.0",
"winston": "^3.19.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Router } from 'express';
import { requireAuth } from './middleware/auth.js';
import authRouter from './routes/auth.js';
import guildsRouter from './routes/guilds.js';
import healthRouter from './routes/health.js';

Expand All @@ -13,7 +14,10 @@ const router = Router();
// Health check — public (no auth required)
router.use('/health', healthRouter);

// Guild routes — require API secret
// Auth routes — public (no auth required)
router.use('/auth', authRouter);

// Guild routes — require API secret or OAuth2 JWT
router.use('/guilds', requireAuth(), guildsRouter);

export default router;
52 changes: 44 additions & 8 deletions src/api/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Authentication Middleware
* Validates requests using a shared API secret
* Supports both shared API secret and OAuth2 JWT authentication
*/

import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { warn } from '../../logger.js';

/**
Expand All @@ -24,23 +25,58 @@ export function isValidSecret(secret) {
}

/**
* Creates middleware that validates the x-api-secret header against BOT_API_SECRET.
* Returns 401 JSON error if the header is missing or does not match.
* Creates middleware that validates either:
* - x-api-secret header (shared secret) — sets req.authMethod = 'api-secret'
* - Authorization: Bearer <jwt> header (OAuth2) — sets req.authMethod = 'oauth', req.user = decoded JWT
*
* Returns 401 JSON error if neither is valid.
*
* @returns {import('express').RequestHandler} Express middleware function
*/
export function requireAuth() {
return (req, res, next) => {
if (!process.env.BOT_API_SECRET) {
warn('BOT_API_SECRET not configured — rejecting API request');
return res.status(401).json({ error: 'API authentication not configured' });
// Try API secret first
const apiSecret = req.headers['x-api-secret'];
if (apiSecret) {
if (!process.env.BOT_API_SECRET) {
warn('BOT_API_SECRET not configured — rejecting API request');
return res.status(401).json({ error: 'API authentication not configured' });
}

if (isValidSecret(apiSecret)) {
req.authMethod = 'api-secret';
return next();
}
}

// Try OAuth2 JWT
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const sessionSecret = process.env.SESSION_SECRET;

if (!sessionSecret) {
return res.status(401).json({ error: 'Session not configured' });
}

try {
const decoded = jwt.verify(token, sessionSecret);
req.authMethod = 'oauth';
req.user = decoded;
return next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}

if (!isValidSecret(req.headers['x-api-secret'])) {
// Neither auth method provided or valid
if (!apiSecret && !authHeader) {
warn('Unauthorized API request', { ip: req.ip, path: req.path });
return res.status(401).json({ error: 'Unauthorized' });
}

next();
// Had a secret but it didn't match
warn('Unauthorized API request', { ip: req.ip, path: req.path });
return res.status(401).json({ error: 'Unauthorized' });
};
}
37 changes: 37 additions & 0 deletions src/api/middleware/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* OAuth2 JWT Middleware
* Verifies JWT tokens from Discord OAuth2 sessions
*/

import jwt from 'jsonwebtoken';

/**
* Creates middleware that verifies a JWT Bearer token from the Authorization header.
* Attaches the decoded user payload to req.user on success.
*
* @returns {import('express').RequestHandler} Express middleware function
*/
export function requireOAuth() {
return (req, res, next) => {
const authHeader = req.headers.authorization;

if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}

const token = authHeader.slice(7);
const sessionSecret = process.env.SESSION_SECRET;

if (!sessionSecret) {
return res.status(500).json({ error: 'Session not configured' });
}

try {
const decoded = jwt.verify(token, sessionSecret);
req.user = decoded;
next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
}
167 changes: 167 additions & 0 deletions src/api/routes/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Auth Routes
* Discord OAuth2 authentication endpoints
*/

import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { error, info } from '../../logger.js';

const router = Router();

const DISCORD_API = 'https://discord.com/api/v10';

/**
* GET /discord — Redirect to Discord OAuth2 authorization
*/
router.get('/discord', (_req, res) => {
const clientId = process.env.DISCORD_CLIENT_ID;
const redirectUri = process.env.DISCORD_REDIRECT_URI;

if (!clientId || !redirectUri) {
return res.status(500).json({ error: 'OAuth2 not configured' });
}

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'identify guilds',
});

res.redirect(`https://discord.com/oauth2/authorize?${params}`);
});

/**
* GET /discord/callback — Handle Discord OAuth2 callback
* Exchanges code for token, fetches user info, creates JWT
*/
router.get('/discord/callback', async (req, res) => {
const { code } = req.query;

if (!code) {
return res.status(400).json({ error: 'Missing authorization code' });
}

const clientId = process.env.DISCORD_CLIENT_ID;
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
const redirectUri = process.env.DISCORD_REDIRECT_URI;
const sessionSecret = process.env.SESSION_SECRET;

if (!clientId || !clientSecret || !redirectUri || !sessionSecret) {
return res.status(500).json({ error: 'OAuth2 not configured' });
}

try {
// Exchange code for access token
const tokenResponse = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
}),
});

if (!tokenResponse.ok) {
error('Discord token exchange failed', { status: tokenResponse.status });
return res.status(401).json({ error: 'Failed to exchange authorization code' });
}

const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;

// Fetch user info
const userResponse = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!userResponse.ok) {
error('Discord user fetch failed', { status: userResponse.status });
return res.status(401).json({ error: 'Failed to fetch user info' });
}

const user = await userResponse.json();

// Fetch user guilds
const guildsResponse = await fetch(`${DISCORD_API}/users/@me/guilds`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!guildsResponse.ok) {
error('Discord guilds fetch failed', { status: guildsResponse.status });
return res.status(401).json({ error: 'Failed to fetch user guilds' });
}

const guilds = await guildsResponse.json();

// Create JWT
const token = jwt.sign(
{
userId: user.id,
username: user.username,
discriminator: user.discriminator,
avatar: user.avatar,
guilds: guilds.map((g) => ({
id: g.id,
name: g.name,
permissions: g.permissions,
})),
},
sessionSecret,
{ expiresIn: '7d' },
);

info('User authenticated via OAuth2', { userId: user.id, username: user.username });

// Redirect with token as query parameter
const dashboardUrl = process.env.DASHBOARD_URL || '/';
res.redirect(`${dashboardUrl}?token=${token}`);
} catch (err) {
error('OAuth2 callback error', { error: err.message });
res.status(500).json({ error: 'Authentication failed' });
}
});

/**
* GET /me — Return current authenticated user info from JWT
*/
router.get('/me', (req, res) => {
const authHeader = req.headers.authorization;

if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}

const token = authHeader.slice(7);
const sessionSecret = process.env.SESSION_SECRET;

if (!sessionSecret) {
return res.status(500).json({ error: 'Session not configured' });
}

try {
const decoded = jwt.verify(token, sessionSecret);
res.json({
userId: decoded.userId,
username: decoded.username,
discriminator: decoded.discriminator,
avatar: decoded.avatar,
guilds: decoded.guilds,
});
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
});

/**
* POST /logout — Placeholder for logout (JWT is stateless, client discards token)
*/
router.post('/logout', (_req, res) => {
res.json({ message: 'Logged out successfully' });
});

export default router;
Loading
Loading