Skip to content

Commit 31948cc

Browse files
committed
merge: resolve conflicts with main for PR #74
2 parents f5f4dd0 + 13cd99f commit 31948cc

28 files changed

Lines changed: 2379 additions & 47 deletions

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ GUILD_ID=your_discord_guild_id
1818
# Discord OAuth2 client secret (required for web dashboard)
1919
DISCORD_CLIENT_SECRET=your_discord_client_secret
2020

21+
# Discord OAuth2 redirect URI (required for web dashboard)
22+
DISCORD_REDIRECT_URI=http://localhost:3001/api/v1/auth/discord/callback
23+
24+
# Session secret for JWT signing (required for OAuth2 dashboard auth)
25+
# Generate with: openssl rand -base64 32
26+
SESSION_SECRET=your_session_secret
27+
2128
# ── OpenClaw ─────────────────────────────────
2229

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

71+
# ── Bot Owner / Permissions ───────────────────
72+
73+
# Bot owner Discord user IDs are configured in config.json under
74+
# permissions.botOwners. The default value is the upstream maintainer's ID.
75+
# **Forks/deployers:** Update config.json permissions.botOwners with your own
76+
# Discord user ID(s). Bot owners bypass all permission checks.
77+
# Find your Discord user ID: User Settings → Advanced → Enable Developer Mode,
78+
# then right-click your name → Copy User ID.
79+
6480
# ── Optional Integrations ────────────────────
6581

6682
# mem0 API key for user long-term memory (optional — memory features disabled without it)

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,15 @@ All configuration lives in `config.json` and can be updated at runtime via the `
187187
|-----|------|-------------|
188188
| `enabled` | boolean | Enable permission checks |
189189
| `adminRoleId` | string | Role ID for admin commands |
190-
| `allowedCommands` | object | Per-command permission levels |
190+
| `moderatorRoleId` | string | Role ID for moderator commands |
191+
| `botOwners` | string[] | Discord user IDs that bypass all permission checks |
192+
| `allowedCommands` | object | Per-command permission levels (`everyone`, `moderator`, `admin`) |
193+
194+
> **⚠️ 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.
191195
192196
## ⚔️ Moderation Commands
193197

194-
All moderation commands require the admin role (configured via `permissions.adminRoleId`).
198+
Most moderation commands require admin-level access. `/modlog` is moderator-level by default (`permissions.allowedCommands.modlog = "moderator"`).
195199

196200
### Core Actions
197201

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

356363
### Web Dashboard Environment Variables

config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
"permissions": {
8383
"enabled": true,
8484
"adminRoleId": null,
85+
"moderatorRoleId": null,
86+
"botOwners": ["191633014441115648"],
8587
"usePermissions": true,
8688
"allowedCommands": {
8789
"ping": "everyone",
@@ -101,7 +103,7 @@
101103
"lock": "admin",
102104
"unlock": "admin",
103105
"slowmode": "admin",
104-
"modlog": "admin"
106+
"modlog": "moderator"
105107
}
106108
}
107109
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"discord.js": "^14.25.1",
2121
"dotenv": "^17.3.1",
2222
"express": "^5.2.1",
23+
"jsonwebtoken": "^9.0.3",
2324
"mem0ai": "^2.2.2",
2425
"pg": "^8.18.0",
2526
"winston": "^3.19.0",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { Router } from 'express';
77
import { requireAuth } from './middleware/auth.js';
8+
import authRouter from './routes/auth.js';
89
import guildsRouter from './routes/guilds.js';
910
import healthRouter from './routes/health.js';
1011

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

16-
// Guild routes — require API secret
17+
// Auth routes — public (no auth required)
18+
router.use('/auth', authRouter);
19+
20+
// Guild routes — require API secret or OAuth2 JWT
1721
router.use('/guilds', requireAuth(), guildsRouter);
1822

1923
export default router;

src/api/middleware/auth.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/**
22
* Authentication Middleware
3-
* Validates requests using a shared API secret
3+
* Supports both shared API secret and OAuth2 JWT authentication
44
*/
55

66
import crypto from 'node:crypto';
77
import { warn } from '../../logger.js';
8+
import { handleOAuthJwt } from './oauthJwt.js';
89

910
/**
1011
* Performs a constant-time comparison of the given secret against BOT_API_SECRET.
@@ -24,23 +25,46 @@ export function isValidSecret(secret) {
2425
}
2526

2627
/**
27-
* Creates middleware that validates the x-api-secret header against BOT_API_SECRET.
28-
* Returns 401 JSON error if the header is missing or does not match.
28+
* Creates middleware that validates either:
29+
* - x-api-secret header (shared secret) — sets req.authMethod = 'api-secret'
30+
* - Authorization: Bearer <jwt> header (OAuth2) — sets req.authMethod = 'oauth', req.user = decoded JWT
31+
*
32+
* Returns 401 JSON error if neither is valid.
2933
*
3034
* @returns {import('express').RequestHandler} Express middleware function
3135
*/
3236
export function requireAuth() {
3337
return (req, res, next) => {
34-
if (!process.env.BOT_API_SECRET) {
35-
warn('BOT_API_SECRET not configured — rejecting API request');
36-
return res.status(401).json({ error: 'API authentication not configured' });
38+
// Try API secret first
39+
const apiSecret = req.headers['x-api-secret'];
40+
if (apiSecret) {
41+
if (!process.env.BOT_API_SECRET) {
42+
// API secret auth is not configured — ignore the header and fall through to JWT.
43+
// This allows clients that always send x-api-secret to still authenticate via JWT
44+
// when the deployer hasn't configured BOT_API_SECRET.
45+
warn('BOT_API_SECRET not configured — ignoring x-api-secret header, trying JWT', {
46+
ip: req.ip,
47+
path: req.path,
48+
});
49+
} else if (isValidSecret(apiSecret)) {
50+
req.authMethod = 'api-secret';
51+
return next();
52+
} else {
53+
// BOT_API_SECRET is configured but the provided secret doesn't match.
54+
// Reject immediately — an explicit API-secret auth attempt that fails
55+
// should not silently fall through to JWT.
56+
warn('Invalid API secret provided', { ip: req.ip, path: req.path });
57+
return res.status(401).json({ error: 'Invalid API secret' });
58+
}
3759
}
3860

39-
if (!isValidSecret(req.headers['x-api-secret'])) {
40-
warn('Unauthorized API request', { ip: req.ip, path: req.path });
41-
return res.status(401).json({ error: 'Unauthorized' });
61+
// Try OAuth2 JWT
62+
if (handleOAuthJwt(req, res, next)) {
63+
return;
4264
}
4365

44-
next();
66+
// Neither auth method provided or valid
67+
warn('Unauthorized API request', { ip: req.ip, path: req.path });
68+
return res.status(401).json({ error: 'Unauthorized' });
4569
};
4670
}

src/api/middleware/oauth.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* OAuth2 JWT Middleware
3+
* Verifies JWT tokens from Discord OAuth2 sessions
4+
*/
5+
6+
import { handleOAuthJwt } from './oauthJwt.js';
7+
8+
/**
9+
* Creates middleware that verifies a JWT Bearer token from the Authorization header.
10+
* Attaches the decoded user payload to req.user on success.
11+
*
12+
* @returns {import('express').RequestHandler} Express middleware function
13+
*/
14+
export function requireOAuth() {
15+
return (req, res, next) => {
16+
return handleOAuthJwt(req, res, next, { missingTokenError: 'No token provided' });
17+
};
18+
}

src/api/middleware/oauthJwt.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Shared OAuth JWT middleware helpers
3+
*/
4+
5+
import { error } from '../../logger.js';
6+
import { verifyJwtToken } from './verifyJwt.js';
7+
8+
/**
9+
* Extract Bearer token from Authorization header.
10+
*
11+
* @param {string|undefined} authHeader - Raw Authorization header value
12+
* @returns {string|null} JWT token if present, otherwise null
13+
*/
14+
export function getBearerToken(authHeader) {
15+
if (!authHeader?.startsWith('Bearer ')) {
16+
return null;
17+
}
18+
return authHeader.slice(7);
19+
}
20+
21+
/**
22+
* Authenticate request using OAuth JWT Bearer token.
23+
*
24+
* @param {import('express').Request} req - Express request
25+
* @param {import('express').Response} res - Express response
26+
* @param {import('express').NextFunction} next - Express next callback
27+
* @param {{ missingTokenError?: string }} [options] - Behavior options
28+
* @returns {boolean} True if middleware chain has been handled, false if no Bearer token was provided and no missing-token error was requested
29+
*/
30+
export function handleOAuthJwt(req, res, next, options = {}) {
31+
const token = getBearerToken(req.headers.authorization);
32+
if (!token) {
33+
if (options.missingTokenError) {
34+
res.status(401).json({ error: options.missingTokenError });
35+
return true;
36+
}
37+
return false;
38+
}
39+
40+
const result = verifyJwtToken(token);
41+
if (result.error) {
42+
if (result.status === 500) {
43+
error('SESSION_SECRET not configured — cannot verify OAuth token', {
44+
ip: req.ip,
45+
path: req.path,
46+
});
47+
}
48+
res.status(result.status).json({ error: result.error });
49+
return true;
50+
}
51+
52+
req.authMethod = 'oauth';
53+
req.user = result.user;
54+
next();
55+
return true;
56+
}

src/api/middleware/verifyJwt.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* JWT Verification Helper
3+
* Shared JWT verification logic used by both requireAuth and requireOAuth middleware
4+
*/
5+
6+
import jwt from 'jsonwebtoken';
7+
import { getSessionToken } from '../utils/sessionStore.js';
8+
9+
/**
10+
* Lazily cached SESSION_SECRET — read from env on first call, then reused.
11+
* Avoids per-request env lookup while remaining compatible with test stubs
12+
* (vi.stubEnv sets process.env before the first call within each test).
13+
* Call `_resetSecretCache()` in test teardown if needed.
14+
*/
15+
let _cachedSecret;
16+
17+
/** @internal Reset the cached secret (for test teardown). */
18+
export function _resetSecretCache() {
19+
_cachedSecret = undefined;
20+
}
21+
22+
function getSecret() {
23+
if (_cachedSecret === undefined) {
24+
_cachedSecret = process.env.SESSION_SECRET || '';
25+
}
26+
return _cachedSecret;
27+
}
28+
29+
/**
30+
* Verify a JWT token and validate the associated server-side session.
31+
*
32+
* @param {string} token - The JWT Bearer token to verify
33+
* @returns {{ user: Object } | { error: string, status: number }}
34+
* On success: `{ user }` with the decoded JWT payload.
35+
* On failure: `{ error, status }` with an error message and HTTP status code.
36+
*/
37+
export function verifyJwtToken(token) {
38+
const secret = getSecret();
39+
if (!secret) return { error: 'Session not configured', status: 500 };
40+
41+
try {
42+
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
43+
if (!getSessionToken(decoded.userId)) {
44+
return { error: 'Session expired or revoked', status: 401 };
45+
}
46+
return { user: decoded };
47+
} catch {
48+
return { error: 'Invalid or expired token', status: 401 };
49+
}
50+
}

0 commit comments

Comments
 (0)