- 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>
185 lines
4.2 KiB
JavaScript
185 lines
4.2 KiB
JavaScript
// 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,
|
|
};
|