Skip to content

Commit 9baa88d

Browse files
dsshimelclaude
andcommitted
remove JWT auth, consolidate on Discord OAuth (BetterAuth) sessions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b422106 commit 9baa88d

File tree

12 files changed

+124
-662
lines changed

12 files changed

+124
-662
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# 2026-02-04-001: Remove JWT Authentication
2+
3+
## Summary
4+
5+
Removed all JWT-based authentication from the attendabot admin panel, consolidating on BetterAuth (Discord OAuth) session cookies as the sole auth method. The password login form has been removed — login is now Discord-only.
6+
7+
## What We Did
8+
9+
### Backend
10+
11+
1. **Simplified `auth.ts` middleware** — removed `jwt` import, `getJwtSecret()`, `generateToken()`, `verifyCredentials()`, `verifyPassword()`, `getValidUsernames()`, and all instructor password logic. `authenticateToken()` now only verifies BetterAuth session cookies.
12+
2. **Cleaned up `auth.ts` routes** — removed `POST /login` (JWT token issuance) and `GET /usernames` (password login dropdown) endpoints. Only `GET /login-config` remains.
13+
3. **Simplified `websocket.ts`** — removed JWT token extraction/verification from WebSocket upgrade handler. Now authenticates exclusively via BetterAuth session cookies on the upgrade request.
14+
15+
### Frontend
16+
17+
1. **Stripped JWT from `client.ts`** — removed `getToken()`, `setToken()`, `clearToken()`, `isLoggedIn()`, `verifySession()`, `login()`, `getUsernames()`, `getLoginConfig()`. Renamed `clearToken()` to `clearSession()`. `fetchWithAuth()` now sends `credentials: "include"` instead of Bearer headers.
18+
2. **Simplified `useWebSocket` hook** — removed `token` parameter entirely. Always connects without token query param; cookies are sent automatically with the upgrade request.
19+
3. **Simplified `ServerLogs`** — removed `getToken` import, calls `useWebSocket()` with no args.
20+
4. **Discord-only `Login` component** — removed password form, username dropdown, and related state. Only the Discord OAuth button remains.
21+
5. **Simplified `App.tsx`** — removed all JWT session checks (`isLoggedIn`, `verifySession`, `getUsername`). Auth state is determined solely by BetterAuth `getSession()`.
22+
23+
### Tests
24+
25+
Rewrote `auth.test.ts` from 21 JWT-focused tests down to 4 BetterAuth session tests covering: no session (401), valid session, email fallback for username, and error handling.
26+
27+
## Files Modified
28+
- `backend/src/api/middleware/auth.ts` - BetterAuth-only middleware
29+
- `backend/src/api/routes/auth.ts` - Removed login/usernames endpoints
30+
- `backend/src/api/websocket.ts` - Cookie-only WebSocket auth
31+
- `backend/src/test/api/auth.test.ts` - BetterAuth session tests
32+
- `frontend/src/api/client.ts` - Removed JWT storage/retrieval, cookie-based fetch
33+
- `frontend/src/hooks/useWebSocket.ts` - No token parameter
34+
- `frontend/src/components/ServerLogs.tsx` - Removed token usage
35+
- `frontend/src/components/Login.tsx` - Discord-only login
36+
- `frontend/src/App.tsx` - BetterAuth-only session management

attendabot/backend/src/api/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import path from "path";
99
import http from "http";
1010
import { toNodeHandler } from "better-auth/node";
1111
import { auth } from "../auth";
12-
import { authRouter } from "./routes/auth";
1312
import { statusRouter } from "./routes/status";
1413
import { messagesRouter } from "./routes/messages";
1514
import { channelsRouter } from "./routes/channels";
@@ -40,7 +39,6 @@ app.all("/api/auth/better/*", toNodeHandler(auth));
4039
app.use(express.json());
4140

4241
// API routes
43-
app.use("/api/auth", authRouter);
4442
app.use("/api/status", statusRouter);
4543
app.use("/api/messages", messagesRouter);
4644
app.use("/api/channels", channelsRouter);
Lines changed: 5 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,23 @@
11
/**
2-
* @fileoverview JWT authentication middleware and utilities for the admin API.
3-
* Supports multi-user authentication with predefined instructor accounts.
2+
* @fileoverview Authentication middleware for the admin API.
3+
* Uses BetterAuth (Discord OAuth) session cookies for authentication.
44
*/
55

66
import { Request, Response, NextFunction } from "express";
7-
import jwt from "jsonwebtoken";
87
import { fromNodeHeaders } from "better-auth/node";
98
import { auth } from "../../auth";
10-
import dotenv from "dotenv";
11-
12-
dotenv.config();
13-
14-
/** Reads JWT secret from environment at call time. */
15-
function getJwtSecret(): string {
16-
return process.env.JWT_SECRET || "default-secret-change-me";
17-
}
18-
19-
/** Predefined instructor usernames. Passwords are read from env vars at call time. */
20-
const INSTRUCTOR_NAMES = ["David", "Paris", "Andrew", "Liam"] as const;
21-
22-
/** Reads an instructor's password from the environment at call time. */
23-
function getInstructorPassword(name: string): string | undefined {
24-
if (!(INSTRUCTOR_NAMES as readonly string[]).includes(name)) return undefined;
25-
return process.env[`INSTRUCTOR_${name.toUpperCase()}_PASSWORD`];
26-
}
279

2810
/** Express Request extended with authenticated user data. */
2911
export interface AuthRequest extends Request {
3012
user?: { authenticated: boolean; username: string };
3113
}
3214

33-
/** Express middleware that validates JWT or BetterAuth session. */
15+
/** Express middleware that validates a BetterAuth session cookie. */
3416
export function authenticateToken(
3517
req: AuthRequest,
3618
res: Response,
3719
next: NextFunction,
3820
): void {
39-
const authHeader = req.headers["authorization"];
40-
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
41-
42-
// Try JWT first (existing auth)
43-
if (token) {
44-
try {
45-
const decoded = jwt.verify(token, getJwtSecret()) as {
46-
authenticated: boolean;
47-
username: string;
48-
};
49-
req.user = decoded;
50-
next();
51-
return;
52-
} catch {
53-
// JWT invalid — fall through to try BetterAuth session
54-
}
55-
}
56-
57-
// Try BetterAuth session (cookie-based)
5821
auth.api.getSession({
5922
headers: fromNodeHeaders(req.headers),
6023
}).then((session) => {
@@ -64,58 +27,10 @@ export function authenticateToken(
6427
username: session.user.name || session.user.email || "Discord User",
6528
};
6629
next();
67-
} else if (!token) {
68-
res.status(401).json({ error: "Access token required" });
6930
} else {
70-
res.status(403).json({ error: "Invalid or expired token" });
31+
res.status(401).json({ error: "Authentication required" });
7132
}
7233
}).catch(() => {
73-
if (!token) {
74-
res.status(401).json({ error: "Access token required" });
75-
} else {
76-
res.status(403).json({ error: "Invalid or expired token" });
77-
}
34+
res.status(401).json({ error: "Authentication required" });
7835
});
7936
}
80-
81-
/** Generates a signed JWT valid for 24 hours, including the username. */
82-
export function generateToken(username: string): string {
83-
return jwt.sign({ authenticated: true, username }, getJwtSecret(), {
84-
expiresIn: "24h",
85-
});
86-
}
87-
88-
/**
89-
* Verifies credentials for a given username and password.
90-
* Checks instructor-specific passwords first, then falls back to admin password.
91-
*/
92-
export function verifyCredentials(username: string, password: string): boolean {
93-
// Check if this is a known instructor with a configured password
94-
const instructorPassword = getInstructorPassword(username);
95-
if (instructorPassword && password === instructorPassword) {
96-
return true;
97-
}
98-
99-
// Fall back to admin password for backwards compatibility
100-
const adminPassword = process.env.ADMIN_PASSWORD;
101-
if (adminPassword && password === adminPassword) {
102-
return true;
103-
}
104-
105-
return false;
106-
}
107-
108-
/** Returns the list of valid instructor usernames. */
109-
export function getValidUsernames(): string[] {
110-
return [...INSTRUCTOR_NAMES];
111-
}
112-
113-
/** Checks if the provided password matches the admin password. (Legacy support) */
114-
export function verifyPassword(password: string): boolean {
115-
const adminPassword = process.env.ADMIN_PASSWORD;
116-
if (!adminPassword) {
117-
console.warn("ADMIN_PASSWORD not set in environment");
118-
return false;
119-
}
120-
return password === adminPassword;
121-
}

attendabot/backend/src/api/routes/auth.ts

Lines changed: 0 additions & 52 deletions
This file was deleted.

attendabot/backend/src/api/websocket.ts

Lines changed: 17 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
22
* @fileoverview WebSocket server for real-time log streaming.
3-
* Handles client connections, JWT authentication, and log broadcasting.
3+
* Handles client connections, BetterAuth session authentication, and log broadcasting.
44
*/
55

6-
import { Server as HttpServer, IncomingMessage } from "http";
6+
import { Server as HttpServer } from "http";
77
import { WebSocketServer, WebSocket } from "ws";
8-
import { URL } from "url";
9-
import jwt from "jsonwebtoken";
8+
import { fromNodeHeaders } from "better-auth/node";
9+
import { auth } from "../auth";
1010
import { subscribeToLogs, getRecentLogs, LogEntry } from "../services/logger";
1111

1212
/** WebSocket message types sent to clients. */
@@ -16,45 +16,6 @@ interface WsMessage {
1616
log?: LogEntry;
1717
}
1818

19-
/**
20-
* Gets the JWT secret from environment variables.
21-
* Read at runtime to ensure dotenv has been configured.
22-
*/
23-
function getJwtSecret(): string {
24-
return process.env.JWT_SECRET || "default-secret-change-me";
25-
}
26-
27-
/**
28-
* Verifies a JWT token from WebSocket query params.
29-
* @param token - The JWT token to verify.
30-
* @returns True if the token is valid, false otherwise.
31-
*/
32-
function verifyToken(token: string): boolean {
33-
try {
34-
jwt.verify(token, getJwtSecret());
35-
return true;
36-
} catch {
37-
return false;
38-
}
39-
}
40-
41-
/**
42-
* Extracts the token from a WebSocket upgrade request URL.
43-
* @param request - The HTTP upgrade request.
44-
* @returns The token if present, null otherwise.
45-
*/
46-
function extractToken(request: IncomingMessage): string | null {
47-
try {
48-
// Build full URL from request
49-
const host = request.headers.host || "localhost";
50-
const protocol = "http";
51-
const fullUrl = new URL(request.url || "/", `${protocol}://${host}`);
52-
return fullUrl.searchParams.get("token");
53-
} catch {
54-
return null;
55-
}
56-
}
57-
5819
/**
5920
* Initializes the WebSocket server and attaches it to the HTTP server.
6021
* @param httpServer - The HTTP server instance to attach to.
@@ -72,17 +33,21 @@ export function initializeWebSocket(httpServer: HttpServer): void {
7233
return;
7334
}
7435

75-
// Extract and verify token
76-
const token = extractToken(request);
77-
if (!token || !verifyToken(token)) {
36+
// Verify BetterAuth session (cookie-based)
37+
auth.api.getSession({
38+
headers: fromNodeHeaders(request.headers),
39+
}).then((session) => {
40+
if (session?.user) {
41+
wss.handleUpgrade(request, socket, head, (ws) => {
42+
wss.emit("connection", ws, request);
43+
});
44+
} else {
45+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
46+
socket.destroy();
47+
}
48+
}).catch(() => {
7849
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
7950
socket.destroy();
80-
return;
81-
}
82-
83-
// Upgrade connection
84-
wss.handleUpgrade(request, socket, head, (ws) => {
85-
wss.emit("connection", ws, request);
8651
});
8752
});
8853

attendabot/backend/src/services/db.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,6 @@ function seedDefaultFeatureFlags(): void {
192192
1,
193193
"Include next day's assignment in the EOD reminder message"
194194
);
195-
stmt.run(
196-
"password_login_enabled",
197-
0,
198-
"Show the username/password login form (disable to show Discord login only)"
199-
);
200195
}
201196

202197
/** Seeds default cohorts (Fa2025, Sp2026) if they don't already exist. */

0 commit comments

Comments
 (0)