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