feat: Add OIDC authentication with Authentik integration
- Add OIDC login flow with Authentik provider - Implement session-based auth with Redis store - Add avatar display from OIDC claims - Fix input field performance with react-textarea-autosize - Stabilize callbacks to prevent unnecessary re-renders - Fix history loading to skip empty session files - Add 2-row default height for input textarea 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,32 @@ import cors from 'cors';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { join, basename, extname } from 'path';
|
||||
import multer from 'multer';
|
||||
import session from 'express-session';
|
||||
import { createClient } from 'redis';
|
||||
import RedisStore from 'connect-redis';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
// Auth modules
|
||||
import { authConfig, validateConfig } from './config/auth.js';
|
||||
import { initializeOIDC } from './utils/oidc.js';
|
||||
import { requireAuth, optionalAuth, authenticateWebSocket } from './middleware/auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
// Trust proxy - required for secure cookies behind reverse proxy (NPM)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// CORS configuration - allow credentials for cookies
|
||||
app.use(cors({
|
||||
origin: authConfig.app.frontendUrl,
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Session store reference (set after Redis connection)
|
||||
let sessionStore = null;
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
@@ -137,8 +159,11 @@ function scanProjects(basePath, depth = 0, maxDepth = 1) {
|
||||
return projects;
|
||||
}
|
||||
|
||||
// REST endpoint to list hosts
|
||||
app.get('/api/hosts', (req, res) => {
|
||||
// Function to register API routes (called after session middleware is set up)
|
||||
function registerApiRoutes() {
|
||||
|
||||
// REST endpoint to list hosts (protected)
|
||||
app.get('/api/hosts', requireAuth, (req, res) => {
|
||||
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
|
||||
id,
|
||||
name: host.name,
|
||||
@@ -151,8 +176,8 @@ app.get('/api/hosts', (req, res) => {
|
||||
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
|
||||
});
|
||||
|
||||
// REST endpoint to list projects for a host
|
||||
app.get('/api/projects', (req, res) => {
|
||||
// REST endpoint to list projects for a host (protected)
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||
const host = hostsConfig.hosts[hostId];
|
||||
|
||||
@@ -210,7 +235,7 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
|
||||
// Browse directories on a host (for directory picker)
|
||||
app.get('/api/browse', async (req, res) => {
|
||||
app.get('/api/browse', requireAuth, async (req, res) => {
|
||||
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
|
||||
const path = req.query.path || '~';
|
||||
const host = hostsConfig.hosts[hostId];
|
||||
@@ -304,7 +329,7 @@ app.get('/api/browse', async (req, res) => {
|
||||
});
|
||||
|
||||
// File upload endpoint
|
||||
app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => {
|
||||
app.post('/api/upload/:sessionId', requireAuth, upload.array('files', 5), async (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
@@ -443,12 +468,14 @@ function parseHistoryContent(content) {
|
||||
}
|
||||
|
||||
// Get session history for a project (supports SSH hosts)
|
||||
app.get('/api/history/:project', async (req, res) => {
|
||||
app.get('/api/history/:project', requireAuth, async (req, res) => {
|
||||
console.log(`[History] Request for project: ${req.params.project}, host: ${req.query.host}`);
|
||||
try {
|
||||
const projectPath = decodeURIComponent(req.params.project);
|
||||
const hostId = req.query.host;
|
||||
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||
const isSSH = host?.connection?.type === 'ssh';
|
||||
console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`);
|
||||
|
||||
// Convert project path to Claude's folder naming convention
|
||||
const projectFolder = projectPath.replace(/\//g, '-');
|
||||
@@ -459,8 +486,9 @@ app.get('/api/history/:project', async (req, res) => {
|
||||
const sshTarget = `${user}@${sshHost}`;
|
||||
const historyDir = `~/.claude/projects/${projectFolder}`;
|
||||
|
||||
// Find latest session file via SSH
|
||||
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
|
||||
// Find latest non-empty session file via SSH (skip agent files and empty files)
|
||||
// Using a simpler approach: find non-empty files with find command
|
||||
const findCmd = `find ${historyDir} -maxdepth 1 -name '*.jsonl' ! -name 'agent-*' -size +0 -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`;
|
||||
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
@@ -483,6 +511,7 @@ app.get('/api/history/:project', async (req, res) => {
|
||||
const sessionId = basename(latestFile).replace('.jsonl', '');
|
||||
const messages = parseHistoryContent(content);
|
||||
|
||||
console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`);
|
||||
return res.json({ messages, sessionId, source: 'ssh' });
|
||||
} catch (sshErr) {
|
||||
console.error('SSH history fetch error:', sshErr.message);
|
||||
@@ -497,14 +526,16 @@ app.get('/api/history/:project', async (req, res) => {
|
||||
return res.json({ messages: [], sessionId: null });
|
||||
}
|
||||
|
||||
// Find the most recent non-agent session file
|
||||
// Find the most recent non-agent, non-empty session file
|
||||
const files = readdirSync(historyDir)
|
||||
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: join(historyDir, f),
|
||||
mtime: statSync(join(historyDir, f)).mtime
|
||||
mtime: statSync(join(historyDir, f)).mtime,
|
||||
size: statSync(join(historyDir, f)).size
|
||||
}))
|
||||
.filter(f => f.size > 0) // Skip empty files
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
@@ -523,10 +554,27 @@ app.get('/api/history/:project', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
} // End of registerApiRoutes function
|
||||
|
||||
wss.on('connection', async (ws, req) => {
|
||||
const sessionId = uuidv4();
|
||||
console.log(`[${sessionId}] New WebSocket connection`);
|
||||
|
||||
// Authenticate WebSocket connection
|
||||
let wsUser = null;
|
||||
if (authConfig.app.authEnabled && sessionStore) {
|
||||
wsUser = await authenticateWebSocket(req, sessionStore);
|
||||
if (!wsUser) {
|
||||
console.log(`[${sessionId}] WebSocket authentication failed - closing connection`);
|
||||
ws.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
console.log(`[${sessionId}] WebSocket authenticated as: ${wsUser.email}`);
|
||||
} else {
|
||||
// Auth disabled - use anonymous user
|
||||
wsUser = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'], isAdmin: true };
|
||||
}
|
||||
|
||||
let claudeProcess = null;
|
||||
let currentProject = null;
|
||||
let currentHostId = null; // Track current host for restart
|
||||
@@ -642,7 +690,7 @@ wss.on('connection', (ws, req) => {
|
||||
});
|
||||
}
|
||||
|
||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId });
|
||||
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser });
|
||||
|
||||
sendToClient('session_started', {
|
||||
sessionId,
|
||||
@@ -785,7 +833,8 @@ wss.on('connection', (ws, req) => {
|
||||
// Handle stderr
|
||||
claudeProcess.stderr.on('data', (data) => {
|
||||
const content = data.toString();
|
||||
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
|
||||
// Always log stderr for SSH connections (exit code 255 debugging)
|
||||
if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content);
|
||||
sendToClient('stderr', { content });
|
||||
});
|
||||
|
||||
@@ -992,7 +1041,77 @@ wss.on('connection', (ws, req) => {
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
|
||||
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
|
||||
// Initialize and start server
|
||||
async function startServer() {
|
||||
// Validate auth config
|
||||
if (!validateConfig()) {
|
||||
console.error('[Server] Auth configuration invalid');
|
||||
if (authConfig.app.authEnabled) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Redis and session store
|
||||
if (authConfig.app.authEnabled) {
|
||||
try {
|
||||
console.log('[Server] Connecting to Redis...');
|
||||
const redisClient = createClient({ url: authConfig.redis.url });
|
||||
redisClient.on('error', err => console.error('[Redis] Error:', err));
|
||||
await redisClient.connect();
|
||||
console.log('[Server] Redis connected');
|
||||
|
||||
// Create session store
|
||||
sessionStore = new RedisStore({ client: redisClient });
|
||||
|
||||
// Configure session middleware
|
||||
app.use(session({
|
||||
store: sessionStore,
|
||||
name: authConfig.session.name,
|
||||
secret: authConfig.session.secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: authConfig.session.secure,
|
||||
sameSite: 'lax',
|
||||
maxAge: authConfig.session.maxAge,
|
||||
domain: authConfig.session.domain,
|
||||
},
|
||||
}));
|
||||
|
||||
// Initialize OIDC client
|
||||
console.log('[Server] Initializing OIDC...');
|
||||
await initializeOIDC();
|
||||
console.log('[Server] OIDC initialized');
|
||||
|
||||
// Mount auth routes
|
||||
app.use('/auth', authRoutes);
|
||||
console.log('[Server] Auth routes mounted at /auth');
|
||||
|
||||
// Register API routes (after session middleware is set up)
|
||||
registerApiRoutes();
|
||||
console.log('[Server] API routes registered');
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to initialize auth:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('[Server] Authentication disabled');
|
||||
// Register API routes (no session middleware needed when auth is disabled)
|
||||
registerApiRoutes();
|
||||
console.log('[Server] API routes registered');
|
||||
}
|
||||
|
||||
// Start listening
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
|
||||
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
|
||||
console.log(`Authentication: ${authConfig.app.authEnabled ? 'ENABLED' : 'DISABLED'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the server
|
||||
startServer().catch(err => {
|
||||
console.error('[Server] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user