Skip to content
Merged
Show file tree
Hide file tree
Changes from 45 commits
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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ GUILD_ID=your_discord_guild_id
# Discord OAuth2 client secret (required for web dashboard)
DISCORD_CLIENT_SECRET=your_discord_client_secret

# Discord OAuth2 redirect URI (required for web dashboard)
DISCORD_REDIRECT_URI=http://localhost:3001/api/v1/auth/discord/callback

# Session secret for JWT signing (required for OAuth2 dashboard auth)
# Generate with: openssl rand -base64 32
SESSION_SECRET=your_session_secret

# ── OpenClaw ─────────────────────────────────

# OpenClaw chat completions endpoint (required)
Expand Down Expand Up @@ -61,6 +68,15 @@ DASHBOARD_URL=http://localhost:3000
# Discord client ID exposed to browser (required for web dashboard)
NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id

# ── Bot Owner / Permissions ───────────────────

# Bot owner Discord user IDs are configured in config.json under
# permissions.botOwners. The default value is the upstream maintainer's ID.
# **Forks/deployers:** Update config.json permissions.botOwners with your own
# Discord user ID(s). Bot owners bypass all permission checks.
# Find your Discord user ID: User Settings → Advanced → Enable Developer Mode,
# then right-click your name → Copy User ID.

# ── Optional Integrations ────────────────────

# mem0 API key for user long-term memory (optional — memory features disabled without it)
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ All configuration lives in `config.json` and can be updated at runtime via the `
|-----|------|-------------|
| `enabled` | boolean | Enable permission checks |
| `adminRoleId` | string | Role ID for admin commands |
| `allowedCommands` | object | Per-command permission levels |
| `moderatorRoleId` | string | Role ID for moderator commands |
| `botOwners` | string[] | Discord user IDs that bypass all permission checks |
| `allowedCommands` | object | Per-command permission levels (`everyone`, `moderator`, `admin`) |

> **⚠️ For forks/deployers:** The default `config.json` ships with the upstream maintainer's Discord user ID in `permissions.botOwners`. Update this array with your own Discord user ID(s) before deploying. Bot owners bypass all permission checks.

## ⚔️ Moderation Commands

Expand Down Expand Up @@ -351,6 +355,9 @@ Set these in the Railway dashboard for the Bot service:
| `DATABASE_URL` | Yes | `${{Postgres.DATABASE_URL}}` — Railway variable reference |
| `MEM0_API_KEY` | No | Mem0 API key for long-term memory |
| `LOG_LEVEL` | No | `debug`, `info`, `warn`, or `error` (default: `info`) |
| `SESSION_SECRET` | Yes | JWT signing secret for OAuth2 sessions. Generate with `openssl rand -base64 32` |
| `DISCORD_CLIENT_SECRET` | Yes | Discord OAuth2 client secret (required for dashboard auth) |
| `DISCORD_REDIRECT_URI` | Yes | OAuth2 callback URL (e.g. `https://your-bot/api/v1/auth/discord/callback`) |
| `BOT_API_SECRET` | Yes | Shared secret for web dashboard API auth |

### Web Dashboard Environment Variables
Expand Down
4 changes: 3 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
"permissions": {
"enabled": true,
"adminRoleId": null,
"moderatorRoleId": null,
"botOwners": ["191633014441115648"],
"usePermissions": true,
"allowedCommands": {
"ping": "everyone",
Expand All @@ -101,7 +103,7 @@
"lock": "admin",
"unlock": "admin",
"slowmode": "admin",
"modlog": "admin"
"modlog": "moderator"
}
}
}
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;
60 changes: 49 additions & 11 deletions src/api/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* Authentication Middleware
* Validates requests using a shared API secret
* Supports both shared API secret and OAuth2 JWT authentication
*/

import crypto from 'node:crypto';
import { warn } from '../../logger.js';
import { error, warn } from '../../logger.js';
import { verifyJwtToken } from './verifyJwt.js';

/**
* Performs a constant-time comparison of the given secret against BOT_API_SECRET.
Expand All @@ -24,23 +25,60 @@ 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) {
// API secret auth is not configured — ignore the header and fall through to JWT.
// This allows clients that always send x-api-secret to still authenticate via JWT
// when the deployer hasn't configured BOT_API_SECRET.
warn('BOT_API_SECRET not configured — ignoring x-api-secret header, trying JWT', {
ip: req.ip,
path: req.path,
});
} else if (isValidSecret(apiSecret)) {
req.authMethod = 'api-secret';
return next();
} else {
// BOT_API_SECRET is configured but the provided secret doesn't match.
// Reject immediately — an explicit API-secret auth attempt that fails
// should not silently fall through to JWT.
warn('Invalid API secret provided', { ip: req.ip, path: req.path });
return res.status(401).json({ error: 'Invalid API secret' });
}
}

if (!isValidSecret(req.headers['x-api-secret'])) {
warn('Unauthorized API request', { ip: req.ip, path: req.path });
return res.status(401).json({ error: 'Unauthorized' });
// Try OAuth2 JWT
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const result = verifyJwtToken(token);
if (result.error) {
if (result.status === 500) {
error('SESSION_SECRET not configured — cannot verify OAuth token', {
ip: req.ip,
path: req.path,
});
}
return res.status(result.status).json({ error: result.error });
}
req.authMethod = 'oauth';
req.user = result.user;
return next();
}

next();
// Neither auth method provided or valid
warn('Unauthorized API request', { ip: req.ip, path: req.path });
return res.status(401).json({ error: 'Unauthorized' });
};
}
38 changes: 38 additions & 0 deletions src/api/middleware/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* OAuth2 JWT Middleware
* Verifies JWT tokens from Discord OAuth2 sessions
*/

import { error } from '../../logger.js';
import { verifyJwtToken } from './verifyJwt.js';

/**
* 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 result = verifyJwtToken(token);
if (result.error) {
if (result.status === 500) {
error('SESSION_SECRET not configured — cannot verify OAuth token', {
ip: req.ip,
path: req.path,
});
}
return res.status(result.status).json({ error: result.error });
}
req.authMethod = 'oauth';
req.user = result.user;
return next();
};
}
50 changes: 50 additions & 0 deletions src/api/middleware/verifyJwt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* JWT Verification Helper
* Shared JWT verification logic used by both requireAuth and requireOAuth middleware
*/

import jwt from 'jsonwebtoken';
import { getSessionToken } from '../utils/sessionStore.js';

/**
* Lazily cached SESSION_SECRET — read from env on first call, then reused.
* Avoids per-request env lookup while remaining compatible with test stubs
* (vi.stubEnv sets process.env before the first call within each test).
* Call `_resetSecretCache()` in test teardown if needed.
*/
let _cachedSecret;

/** @internal Reset the cached secret (for test teardown). */
export function _resetSecretCache() {
_cachedSecret = undefined;
}

function getSecret() {
if (_cachedSecret === undefined) {
_cachedSecret = process.env.SESSION_SECRET || '';
}
return _cachedSecret;
}

/**
* Verify a JWT token and validate the associated server-side session.
*
* @param {string} token - The JWT Bearer token to verify
* @returns {{ user: Object } | { error: string, status: number }}
* On success: `{ user }` with the decoded JWT payload.
* On failure: `{ error, status }` with an error message and HTTP status code.
*/
export function verifyJwtToken(token) {
const secret = getSecret();
if (!secret) return { error: 'Session not configured', status: 500 };

try {
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
if (!getSessionToken(decoded.userId)) {
return { error: 'Session expired or revoked', status: 401 };
}
return { user: decoded };
} catch {
return { error: 'Invalid or expired token', status: 401 };
}
}
Loading
Loading