Files
claude-web-ui/backend/routes/auth.js
Nikolas Syring 1186cb1b5e 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>
2025-12-18 06:07:22 +01:00

263 lines
8.2 KiB
JavaScript

// 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;