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:
62
backend/config/auth.js
Normal file
62
backend/config/auth.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Auth configuration from environment variables
|
||||
|
||||
export const authConfig = {
|
||||
// OIDC Configuration
|
||||
oidc: {
|
||||
issuer: process.env.OIDC_ISSUER,
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
redirectUri: process.env.OIDC_REDIRECT_URI,
|
||||
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
|
||||
},
|
||||
|
||||
// Session Configuration
|
||||
session: {
|
||||
secret: process.env.SESSION_SECRET,
|
||||
name: 'claude.sid',
|
||||
domain: process.env.SESSION_DOMAIN || undefined,
|
||||
secure: process.env.SESSION_SECURE === 'true',
|
||||
maxAge: parseInt(process.env.SESSION_MAX_AGE) || 86400000, // 24 hours
|
||||
},
|
||||
|
||||
// Redis Configuration
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
},
|
||||
|
||||
// App Configuration
|
||||
app: {
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
authEnabled: process.env.AUTH_ENABLED !== 'false',
|
||||
},
|
||||
|
||||
// Group Configuration (must match Authentik group names)
|
||||
groups: {
|
||||
admin: 'agent-admins',
|
||||
users: 'agent-users',
|
||||
allowedGroups: ['agent-admins', 'agent-users'],
|
||||
},
|
||||
};
|
||||
|
||||
// Validate required config
|
||||
export function validateConfig() {
|
||||
const { oidc, session, app } = authConfig;
|
||||
const errors = [];
|
||||
|
||||
if (app.authEnabled) {
|
||||
if (!oidc.issuer) errors.push('OIDC_ISSUER is required');
|
||||
if (!oidc.clientId) errors.push('OIDC_CLIENT_ID is required');
|
||||
if (!oidc.clientSecret) errors.push('OIDC_CLIENT_SECRET is required');
|
||||
if (!oidc.redirectUri) errors.push('OIDC_REDIRECT_URI is required');
|
||||
if (!session.secret) errors.push('SESSION_SECRET is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Auth configuration errors:', errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default authConfig;
|
||||
184
backend/middleware/auth.js
Normal file
184
backend/middleware/auth.js
Normal file
@@ -0,0 +1,184 @@
|
||||
// Authentication middleware
|
||||
|
||||
import { authConfig } from '../config/auth.js';
|
||||
|
||||
/**
|
||||
* Require authentication for a route
|
||||
* Sets req.user if authenticated, returns 401 if not
|
||||
*/
|
||||
export function requireAuth(req, res, next) {
|
||||
console.log(`[requireAuth] ${req.method} ${req.path} - session ID: ${req.session?.id || 'NONE'}, has user: ${!!req.session?.user}`);
|
||||
|
||||
// Skip auth if disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
req.user = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check session
|
||||
if (!req.session || !req.session.user) {
|
||||
console.log(`[requireAuth] Unauthorized - no session or user`);
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
loginUrl: '/auth/login',
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require specific group membership
|
||||
* Must be used after requireAuth
|
||||
*/
|
||||
export function requireGroup(allowedGroups) {
|
||||
return (req, res, next) => {
|
||||
// Skip if auth disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
}
|
||||
|
||||
const userGroups = req.user.groups || [];
|
||||
const hasGroup = allowedGroups.some(g => userGroups.includes(g));
|
||||
|
||||
if (!hasGroup) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Required group: ${allowedGroups.join(' or ')}`,
|
||||
userGroups,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication - attach user if present but don't require it
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.session && req.session.user) {
|
||||
req.user = req.session.user;
|
||||
} else {
|
||||
req.user = null;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin
|
||||
*/
|
||||
export function isAdmin(user) {
|
||||
if (!user || !user.groups) return false;
|
||||
return user.groups.includes(authConfig.groups.admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade request
|
||||
* Returns user object or null
|
||||
*/
|
||||
export async function authenticateWebSocket(req, sessionStore) {
|
||||
// Skip auth if disabled
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'] };
|
||||
}
|
||||
|
||||
// Parse session ID from cookies
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const sessionCookie = cookies[authConfig.session.name];
|
||||
|
||||
if (!sessionCookie) {
|
||||
console.log('[WS Auth] No session cookie found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Session cookie format: s:sessionId.signature
|
||||
// We need to extract the session ID
|
||||
const sessionId = decodeSessionCookie(sessionCookie, authConfig.session.secret);
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('[WS Auth] Invalid session cookie');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load session from store
|
||||
return new Promise((resolve) => {
|
||||
sessionStore.get(sessionId, (err, session) => {
|
||||
if (err || !session || !session.user) {
|
||||
console.log('[WS Auth] Session not found or invalid');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[WS Auth] Authenticated user: ${session.user.email}`);
|
||||
resolve(session.user);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookies from header string
|
||||
*/
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
if (!cookieHeader) return cookies;
|
||||
|
||||
cookieHeader.split(';').forEach(cookie => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
cookies[name] = rest.join('=');
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode express-session cookie
|
||||
* Cookie format: s:sessionId.signature (URL encoded)
|
||||
*/
|
||||
function decodeSessionCookie(cookie, secret) {
|
||||
try {
|
||||
// URL decode
|
||||
const decoded = decodeURIComponent(cookie);
|
||||
|
||||
// Check for signed cookie prefix
|
||||
if (!decoded.startsWith('s:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract session ID (before the signature)
|
||||
const withoutPrefix = decoded.substring(2);
|
||||
const dotIndex = withoutPrefix.lastIndexOf('.');
|
||||
|
||||
if (dotIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return withoutPrefix.substring(0, dotIndex);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
requireAuth,
|
||||
requireGroup,
|
||||
optionalAuth,
|
||||
isAdmin,
|
||||
authenticateWebSocket,
|
||||
};
|
||||
@@ -13,6 +13,11 @@
|
||||
"ws": "^8.14.2",
|
||||
"cors": "^2.8.5",
|
||||
"uuid": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"express-session": "^1.18.0",
|
||||
"connect-redis": "^7.1.0",
|
||||
"redis": "^4.6.0",
|
||||
"openid-client": "^5.6.0",
|
||||
"cookie-parser": "^1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
262
backend/routes/auth.js
Normal file
262
backend/routes/auth.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// Authentication routes
|
||||
|
||||
import { Router } from 'express';
|
||||
import { authConfig } from '../config/auth.js';
|
||||
import oidc from '../utils/oidc.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /auth/login
|
||||
* Redirect to OIDC provider for authentication
|
||||
*/
|
||||
router.get('/login', async (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.redirect(authConfig.app.frontendUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PKCE and state values
|
||||
const state = oidc.generateState();
|
||||
const nonce = oidc.generateNonce();
|
||||
const codeVerifier = oidc.generateCodeVerifier();
|
||||
|
||||
// Store in session for validation
|
||||
req.session.authState = state;
|
||||
req.session.authNonce = nonce;
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
|
||||
// Store return URL if provided
|
||||
const returnTo = req.query.returnTo || '/';
|
||||
req.session.returnTo = returnTo;
|
||||
|
||||
// Get authorization URL
|
||||
const authUrl = oidc.getAuthorizationUrl(state, nonce, codeVerifier);
|
||||
|
||||
// Explicitly save session before redirect (required for async stores like Redis)
|
||||
await new Promise((resolve, reject) => {
|
||||
req.session.save((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[Auth] Login initiated, session ID: ${req.session.id}, state: ${state.substring(0, 10)}...`);
|
||||
res.redirect(authUrl);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Login error:', error);
|
||||
res.status(500).json({ error: 'Failed to initiate login' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/callback
|
||||
* Handle OIDC callback after authentication
|
||||
*/
|
||||
router.get('/callback', async (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.redirect(authConfig.app.frontendUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
|
||||
// Check for error from provider
|
||||
if (error) {
|
||||
console.error(`[Auth] Provider error: ${error} - ${error_description}`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=${encodeURIComponent(error_description || error)}`);
|
||||
}
|
||||
|
||||
// Validate state
|
||||
console.log(`[Auth] Callback received, session ID: ${req.session?.id}, stored state: ${req.session?.authState?.substring(0, 10) || 'NONE'}..., received state: ${state?.substring(0, 10)}...`);
|
||||
if (state !== req.session.authState) {
|
||||
console.error(`[Auth] State mismatch - expected: ${req.session.authState}, got: ${state}`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=invalid_state`);
|
||||
}
|
||||
|
||||
// Exchange code for tokens (nonce validation happens inside openid-client)
|
||||
const tokenSet = await oidc.exchangeCode(code, req.session.codeVerifier, req.session.authNonce);
|
||||
|
||||
// Get claims from validated token
|
||||
const claims = oidc.getIdTokenClaims(tokenSet);
|
||||
|
||||
// Extract user info
|
||||
const userInfo = await oidc.getUserInfo(tokenSet.access_token);
|
||||
|
||||
// Debug: Log claims and userInfo to see available fields
|
||||
console.log('[Auth] ID Token claims:', JSON.stringify(claims, null, 2));
|
||||
console.log('[Auth] UserInfo:', JSON.stringify(userInfo, null, 2));
|
||||
|
||||
// Extract groups from claims or userInfo
|
||||
let groups = claims.groups || userInfo.groups || [];
|
||||
|
||||
// Filter to allowed groups
|
||||
groups = groups.filter(g => authConfig.groups.allowedGroups.includes(g));
|
||||
|
||||
// Check if user has any allowed group
|
||||
if (groups.length === 0) {
|
||||
console.error(`[Auth] User ${userInfo.email} has no allowed groups`);
|
||||
return res.redirect(`${authConfig.app.frontendUrl}/login?error=no_access`);
|
||||
}
|
||||
|
||||
// Create user session
|
||||
const user = {
|
||||
id: claims.sub,
|
||||
email: userInfo.email || claims.email,
|
||||
name: userInfo.name || claims.name || userInfo.preferred_username,
|
||||
avatar: userInfo.picture || claims.picture || null,
|
||||
groups,
|
||||
isAdmin: groups.includes(authConfig.groups.admin),
|
||||
};
|
||||
|
||||
// Store user and tokens in session
|
||||
req.session.user = user;
|
||||
req.session.tokens = {
|
||||
accessToken: tokenSet.access_token,
|
||||
refreshToken: tokenSet.refresh_token,
|
||||
idToken: tokenSet.id_token,
|
||||
expiresAt: tokenSet.expires_at ? tokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||
};
|
||||
|
||||
// Debug: Log token info
|
||||
console.log(`[Auth] Token info - expires_at: ${tokenSet.expires_at}, calculated expiresAt: ${req.session.tokens.expiresAt}, has refresh_token: ${!!tokenSet.refresh_token}`);
|
||||
|
||||
// Clear auth state
|
||||
delete req.session.authState;
|
||||
delete req.session.authNonce;
|
||||
delete req.session.codeVerifier;
|
||||
|
||||
// Get return URL
|
||||
const returnTo = req.session.returnTo || '/';
|
||||
delete req.session.returnTo;
|
||||
|
||||
console.log(`[Auth] User ${user.email} logged in successfully (groups: ${groups.join(', ')})`);
|
||||
|
||||
// Redirect to frontend
|
||||
res.redirect(`${authConfig.app.frontendUrl}${returnTo}`);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Callback error:', error);
|
||||
res.redirect(`${authConfig.app.frontendUrl}/login?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/logout
|
||||
* Clear session and optionally redirect to OIDC logout
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const idToken = req.session.tokens?.idToken;
|
||||
const userEmail = req.session.user?.email;
|
||||
|
||||
// Destroy session
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('[Auth] Logout error:', err);
|
||||
return res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
|
||||
// Clear cookie
|
||||
res.clearCookie(authConfig.session.name);
|
||||
|
||||
console.log(`[Auth] User ${userEmail || 'unknown'} logged out`);
|
||||
|
||||
// Return logout URL if available
|
||||
if (idToken) {
|
||||
try {
|
||||
const logoutUrl = oidc.getEndSessionUrl(idToken, authConfig.app.frontendUrl);
|
||||
return res.json({ success: true, logoutUrl });
|
||||
} catch (e) {
|
||||
// Issuer might not support end_session
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/user
|
||||
* Get current authenticated user
|
||||
*/
|
||||
router.get('/user', (req, res) => {
|
||||
console.log(`[Auth] /user called, session ID: ${req.session?.id}, has user: ${!!req.session?.user}`);
|
||||
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({
|
||||
user: { id: 'anonymous', email: 'anonymous@local', name: 'Anonymous', groups: ['agent-admin'], isAdmin: true },
|
||||
authEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.user) {
|
||||
console.log(`[Auth] /user - not authenticated, session: ${JSON.stringify(req.session || {})}`);
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
console.log(`[Auth] /user - returning user: ${req.session.user.email}`);
|
||||
res.json({
|
||||
user: req.session.user,
|
||||
authEnabled: true,
|
||||
expiresAt: req.session.tokens?.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /auth/refresh
|
||||
* Refresh access token
|
||||
*/
|
||||
router.post('/refresh', async (req, res) => {
|
||||
console.log(`[Auth] /refresh called, session ID: ${req.session?.id}, has refreshToken: ${!!req.session?.tokens?.refreshToken}`);
|
||||
|
||||
if (!authConfig.app.authEnabled) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.tokens?.refreshToken) {
|
||||
console.log('[Auth] /refresh - no refresh token in session');
|
||||
return res.status(401).json({ error: 'No refresh token' });
|
||||
}
|
||||
|
||||
try {
|
||||
const newTokenSet = await oidc.refreshTokens(req.session.tokens.refreshToken);
|
||||
|
||||
req.session.tokens = {
|
||||
accessToken: newTokenSet.access_token,
|
||||
refreshToken: newTokenSet.refresh_token || req.session.tokens.refreshToken,
|
||||
idToken: newTokenSet.id_token || req.session.tokens.idToken,
|
||||
expiresAt: newTokenSet.expires_at ? newTokenSet.expires_at * 1000 : Date.now() + 3600000,
|
||||
};
|
||||
|
||||
console.log(`[Auth] Token refreshed for user ${req.session.user?.email}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
expiresAt: req.session.tokens.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Auth] Token refresh failed:', error.message);
|
||||
res.status(401).json({ error: 'Token refresh failed', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /auth/status
|
||||
* Check auth status and config
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({
|
||||
authEnabled: authConfig.app.authEnabled,
|
||||
isAuthenticated: !!(req.session && req.session.user),
|
||||
user: req.session?.user ? {
|
||||
email: req.session.user.email,
|
||||
name: req.session.user.name,
|
||||
isAdmin: req.session.user.isAdmin,
|
||||
} : null,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
146
backend/utils/oidc.js
Normal file
146
backend/utils/oidc.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// OIDC Client Wrapper using openid-client
|
||||
|
||||
import { Issuer, generators } from 'openid-client';
|
||||
import { authConfig } from '../config/auth.js';
|
||||
|
||||
let oidcClient = null;
|
||||
let issuer = null;
|
||||
|
||||
/**
|
||||
* Initialize the OIDC client by discovering the issuer
|
||||
*/
|
||||
export async function initializeOIDC() {
|
||||
if (!authConfig.app.authEnabled) {
|
||||
console.log('[OIDC] Authentication disabled, skipping initialization');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[OIDC] Discovering issuer: ${authConfig.oidc.issuer}`);
|
||||
issuer = await Issuer.discover(authConfig.oidc.issuer);
|
||||
console.log(`[OIDC] Discovered issuer: ${issuer.issuer}`);
|
||||
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: authConfig.oidc.clientId,
|
||||
client_secret: authConfig.oidc.clientSecret,
|
||||
redirect_uris: [authConfig.oidc.redirectUri],
|
||||
response_types: ['code'],
|
||||
});
|
||||
|
||||
console.log('[OIDC] Client initialized successfully');
|
||||
return oidcClient;
|
||||
} catch (error) {
|
||||
console.error('[OIDC] Failed to initialize client:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized OIDC client
|
||||
*/
|
||||
export function getClient() {
|
||||
if (!oidcClient) {
|
||||
throw new Error('OIDC client not initialized. Call initializeOIDC() first.');
|
||||
}
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL for login
|
||||
*/
|
||||
export function getAuthorizationUrl(state, nonce, codeVerifier) {
|
||||
const client = getClient();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
|
||||
return client.authorizationUrl({
|
||||
scope: authConfig.oidc.scopes.join(' '),
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCode(code, codeVerifier, nonce) {
|
||||
const client = getClient();
|
||||
|
||||
const tokenSet = await client.callback(
|
||||
authConfig.oidc.redirectUri,
|
||||
{ code },
|
||||
{ code_verifier: codeVerifier, nonce }
|
||||
);
|
||||
|
||||
return tokenSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode ID token claims
|
||||
*/
|
||||
export function getIdTokenClaims(tokenSet) {
|
||||
return tokenSet.claims();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info from the userinfo endpoint
|
||||
*/
|
||||
export async function getUserInfo(accessToken) {
|
||||
const client = getClient();
|
||||
return await client.userinfo(accessToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export async function refreshTokens(refreshToken) {
|
||||
const client = getClient();
|
||||
return await client.refresh(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get end session URL for logout
|
||||
*/
|
||||
export function getEndSessionUrl(idTokenHint, postLogoutRedirectUri) {
|
||||
const client = getClient();
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idTokenHint,
|
||||
post_logout_redirect_uri: postLogoutRedirectUri,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
export function generateState() {
|
||||
return generators.state();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random nonce for ID token validation
|
||||
*/
|
||||
export function generateNonce() {
|
||||
return generators.nonce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code verifier for PKCE
|
||||
*/
|
||||
export function generateCodeVerifier() {
|
||||
return generators.codeVerifier();
|
||||
}
|
||||
|
||||
export default {
|
||||
initializeOIDC,
|
||||
getClient,
|
||||
getAuthorizationUrl,
|
||||
exchangeCode,
|
||||
getIdTokenClaims,
|
||||
getUserInfo,
|
||||
refreshTokens,
|
||||
getEndSessionUrl,
|
||||
generateState,
|
||||
generateNonce,
|
||||
generateCodeVerifier,
|
||||
};
|
||||
Reference in New Issue
Block a user