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:
2025-12-18 06:07:22 +01:00
parent cfee1711dc
commit 1186cb1b5e
23 changed files with 2884 additions and 87 deletions

View File

@@ -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);
});