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:
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,
|
||||
};
|
||||
Reference in New Issue
Block a user