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

184
backend/middleware/auth.js Normal file
View 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,
};