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

62
backend/config/auth.js Normal file
View File

@@ -0,0 +1,62 @@
// Auth configuration from environment variables
export const authConfig = {
// OIDC Configuration
oidc: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
redirectUri: process.env.OIDC_REDIRECT_URI,
scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'],
},
// Session Configuration
session: {
secret: process.env.SESSION_SECRET,
name: 'claude.sid',
domain: process.env.SESSION_DOMAIN || undefined,
secure: process.env.SESSION_SECURE === 'true',
maxAge: parseInt(process.env.SESSION_MAX_AGE) || 86400000, // 24 hours
},
// Redis Configuration
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
// App Configuration
app: {
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
authEnabled: process.env.AUTH_ENABLED !== 'false',
},
// Group Configuration (must match Authentik group names)
groups: {
admin: 'agent-admins',
users: 'agent-users',
allowedGroups: ['agent-admins', 'agent-users'],
},
};
// Validate required config
export function validateConfig() {
const { oidc, session, app } = authConfig;
const errors = [];
if (app.authEnabled) {
if (!oidc.issuer) errors.push('OIDC_ISSUER is required');
if (!oidc.clientId) errors.push('OIDC_CLIENT_ID is required');
if (!oidc.clientSecret) errors.push('OIDC_CLIENT_SECRET is required');
if (!oidc.redirectUri) errors.push('OIDC_REDIRECT_URI is required');
if (!session.secret) errors.push('SESSION_SECRET is required');
}
if (errors.length > 0) {
console.error('Auth configuration errors:', errors);
return false;
}
return true;
}
export default authConfig;

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,
};

View File

@@ -13,6 +13,11 @@
"ws": "^8.14.2",
"cors": "^2.8.5",
"uuid": "^9.0.0",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"express-session": "^1.18.0",
"connect-redis": "^7.1.0",
"redis": "^4.6.0",
"openid-client": "^5.6.0",
"cookie-parser": "^1.4.6"
}
}

262
backend/routes/auth.js Normal file
View 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;

View File

@@ -7,10 +7,32 @@ import cors from 'cors';
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
import { join, basename, extname } from 'path';
import multer from 'multer';
import session from 'express-session';
import { createClient } from 'redis';
import RedisStore from 'connect-redis';
import cookieParser from 'cookie-parser';
// Auth modules
import { authConfig, validateConfig } from './config/auth.js';
import { initializeOIDC } from './utils/oidc.js';
import { requireAuth, optionalAuth, authenticateWebSocket } from './middleware/auth.js';
import authRoutes from './routes/auth.js';
const app = express();
app.use(cors());
// Trust proxy - required for secure cookies behind reverse proxy (NPM)
app.set('trust proxy', 1);
// CORS configuration - allow credentials for cookies
app.use(cors({
origin: authConfig.app.frontendUrl,
credentials: true,
}));
app.use(express.json());
app.use(cookieParser());
// Session store reference (set after Redis connection)
let sessionStore = null;
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
@@ -137,8 +159,11 @@ function scanProjects(basePath, depth = 0, maxDepth = 1) {
return projects;
}
// REST endpoint to list hosts
app.get('/api/hosts', (req, res) => {
// Function to register API routes (called after session middleware is set up)
function registerApiRoutes() {
// REST endpoint to list hosts (protected)
app.get('/api/hosts', requireAuth, (req, res) => {
const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({
id,
name: host.name,
@@ -151,8 +176,8 @@ app.get('/api/hosts', (req, res) => {
res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' });
});
// REST endpoint to list projects for a host
app.get('/api/projects', (req, res) => {
// REST endpoint to list projects for a host (protected)
app.get('/api/projects', requireAuth, (req, res) => {
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
const host = hostsConfig.hosts[hostId];
@@ -210,7 +235,7 @@ app.get('/api/health', (req, res) => {
});
// Browse directories on a host (for directory picker)
app.get('/api/browse', async (req, res) => {
app.get('/api/browse', requireAuth, async (req, res) => {
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
const path = req.query.path || '~';
const host = hostsConfig.hosts[hostId];
@@ -304,7 +329,7 @@ app.get('/api/browse', async (req, res) => {
});
// File upload endpoint
app.post('/api/upload/:sessionId', upload.array('files', 5), async (req, res) => {
app.post('/api/upload/:sessionId', requireAuth, upload.array('files', 5), async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
@@ -443,12 +468,14 @@ function parseHistoryContent(content) {
}
// Get session history for a project (supports SSH hosts)
app.get('/api/history/:project', async (req, res) => {
app.get('/api/history/:project', requireAuth, async (req, res) => {
console.log(`[History] Request for project: ${req.params.project}, host: ${req.query.host}`);
try {
const projectPath = decodeURIComponent(req.params.project);
const hostId = req.query.host;
const host = hostId ? hostsConfig.hosts[hostId] : null;
const isSSH = host?.connection?.type === 'ssh';
console.log(`[History] Resolved - projectPath: ${projectPath}, hostId: ${hostId}, isSSH: ${isSSH}`);
// Convert project path to Claude's folder naming convention
const projectFolder = projectPath.replace(/\//g, '-');
@@ -459,8 +486,9 @@ app.get('/api/history/:project', async (req, res) => {
const sshTarget = `${user}@${sshHost}`;
const historyDir = `~/.claude/projects/${projectFolder}`;
// Find latest session file via SSH
const findCmd = `ls -t ${historyDir}/*.jsonl 2>/dev/null | grep -v agent- | head -1`;
// Find latest non-empty session file via SSH (skip agent files and empty files)
// Using a simpler approach: find non-empty files with find command
const findCmd = `find ${historyDir} -maxdepth 1 -name '*.jsonl' ! -name 'agent-*' -size +0 -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-`;
const { execSync } = await import('child_process');
try {
@@ -483,6 +511,7 @@ app.get('/api/history/:project', async (req, res) => {
const sessionId = basename(latestFile).replace('.jsonl', '');
const messages = parseHistoryContent(content);
console.log(`[History] SSH - Returning ${messages.length} messages from session ${sessionId}`);
return res.json({ messages, sessionId, source: 'ssh' });
} catch (sshErr) {
console.error('SSH history fetch error:', sshErr.message);
@@ -497,14 +526,16 @@ app.get('/api/history/:project', async (req, res) => {
return res.json({ messages: [], sessionId: null });
}
// Find the most recent non-agent session file
// Find the most recent non-agent, non-empty session file
const files = readdirSync(historyDir)
.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
.map(f => ({
name: f,
path: join(historyDir, f),
mtime: statSync(join(historyDir, f)).mtime
mtime: statSync(join(historyDir, f)).mtime,
size: statSync(join(historyDir, f)).size
}))
.filter(f => f.size > 0) // Skip empty files
.sort((a, b) => b.mtime - a.mtime);
if (files.length === 0) {
@@ -523,10 +554,27 @@ app.get('/api/history/:project', async (req, res) => {
}
});
wss.on('connection', (ws, req) => {
} // End of registerApiRoutes function
wss.on('connection', async (ws, req) => {
const sessionId = uuidv4();
console.log(`[${sessionId}] New WebSocket connection`);
// Authenticate WebSocket connection
let wsUser = null;
if (authConfig.app.authEnabled && sessionStore) {
wsUser = await authenticateWebSocket(req, sessionStore);
if (!wsUser) {
console.log(`[${sessionId}] WebSocket authentication failed - closing connection`);
ws.close(1008, 'Unauthorized');
return;
}
console.log(`[${sessionId}] WebSocket authenticated as: ${wsUser.email}`);
} else {
// Auth disabled - use anonymous user
wsUser = { id: 'anonymous', email: 'anonymous@local', groups: ['agent-admin'], isAdmin: true };
}
let claudeProcess = null;
let currentProject = null;
let currentHostId = null; // Track current host for restart
@@ -642,7 +690,7 @@ wss.on('connection', (ws, req) => {
});
}
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId });
sessions.set(sessionId, { process: claudeProcess, project: projectPath, host: host, hostId: hostId, user: wsUser });
sendToClient('session_started', {
sessionId,
@@ -785,7 +833,8 @@ wss.on('connection', (ws, req) => {
// Handle stderr
claudeProcess.stderr.on('data', (data) => {
const content = data.toString();
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
// Always log stderr for SSH connections (exit code 255 debugging)
if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content);
sendToClient('stderr', { content });
});
@@ -992,7 +1041,77 @@ wss.on('connection', (ws, req) => {
});
});
server.listen(PORT, HOST, () => {
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
// Initialize and start server
async function startServer() {
// Validate auth config
if (!validateConfig()) {
console.error('[Server] Auth configuration invalid');
if (authConfig.app.authEnabled) {
process.exit(1);
}
}
// Initialize Redis and session store
if (authConfig.app.authEnabled) {
try {
console.log('[Server] Connecting to Redis...');
const redisClient = createClient({ url: authConfig.redis.url });
redisClient.on('error', err => console.error('[Redis] Error:', err));
await redisClient.connect();
console.log('[Server] Redis connected');
// Create session store
sessionStore = new RedisStore({ client: redisClient });
// Configure session middleware
app.use(session({
store: sessionStore,
name: authConfig.session.name,
secret: authConfig.session.secret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: authConfig.session.secure,
sameSite: 'lax',
maxAge: authConfig.session.maxAge,
domain: authConfig.session.domain,
},
}));
// Initialize OIDC client
console.log('[Server] Initializing OIDC...');
await initializeOIDC();
console.log('[Server] OIDC initialized');
// Mount auth routes
app.use('/auth', authRoutes);
console.log('[Server] Auth routes mounted at /auth');
// Register API routes (after session middleware is set up)
registerApiRoutes();
console.log('[Server] API routes registered');
} catch (error) {
console.error('[Server] Failed to initialize auth:', error);
process.exit(1);
}
} else {
console.log('[Server] Authentication disabled');
// Register API routes (no session middleware needed when auth is disabled)
registerApiRoutes();
console.log('[Server] API routes registered');
}
// Start listening
server.listen(PORT, HOST, () => {
console.log(`Claude Web UI Backend running on http://${HOST}:${PORT}`);
console.log(`WebSocket available at ws://${HOST}:${PORT}`);
console.log(`Authentication: ${authConfig.app.authEnabled ? 'ENABLED' : 'DISABLED'}`);
});
}
// Start the server
startServer().catch(err => {
console.error('[Server] Fatal error:', err);
process.exit(1);
});

146
backend/utils/oidc.js Normal file
View File

@@ -0,0 +1,146 @@
// OIDC Client Wrapper using openid-client
import { Issuer, generators } from 'openid-client';
import { authConfig } from '../config/auth.js';
let oidcClient = null;
let issuer = null;
/**
* Initialize the OIDC client by discovering the issuer
*/
export async function initializeOIDC() {
if (!authConfig.app.authEnabled) {
console.log('[OIDC] Authentication disabled, skipping initialization');
return null;
}
try {
console.log(`[OIDC] Discovering issuer: ${authConfig.oidc.issuer}`);
issuer = await Issuer.discover(authConfig.oidc.issuer);
console.log(`[OIDC] Discovered issuer: ${issuer.issuer}`);
oidcClient = new issuer.Client({
client_id: authConfig.oidc.clientId,
client_secret: authConfig.oidc.clientSecret,
redirect_uris: [authConfig.oidc.redirectUri],
response_types: ['code'],
});
console.log('[OIDC] Client initialized successfully');
return oidcClient;
} catch (error) {
console.error('[OIDC] Failed to initialize client:', error.message);
throw error;
}
}
/**
* Get the initialized OIDC client
*/
export function getClient() {
if (!oidcClient) {
throw new Error('OIDC client not initialized. Call initializeOIDC() first.');
}
return oidcClient;
}
/**
* Generate authorization URL for login
*/
export function getAuthorizationUrl(state, nonce, codeVerifier) {
const client = getClient();
const codeChallenge = generators.codeChallenge(codeVerifier);
return client.authorizationUrl({
scope: authConfig.oidc.scopes.join(' '),
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
}
/**
* Exchange authorization code for tokens
*/
export async function exchangeCode(code, codeVerifier, nonce) {
const client = getClient();
const tokenSet = await client.callback(
authConfig.oidc.redirectUri,
{ code },
{ code_verifier: codeVerifier, nonce }
);
return tokenSet;
}
/**
* Validate and decode ID token claims
*/
export function getIdTokenClaims(tokenSet) {
return tokenSet.claims();
}
/**
* Get user info from the userinfo endpoint
*/
export async function getUserInfo(accessToken) {
const client = getClient();
return await client.userinfo(accessToken);
}
/**
* Refresh access token using refresh token
*/
export async function refreshTokens(refreshToken) {
const client = getClient();
return await client.refresh(refreshToken);
}
/**
* Get end session URL for logout
*/
export function getEndSessionUrl(idTokenHint, postLogoutRedirectUri) {
const client = getClient();
return client.endSessionUrl({
id_token_hint: idTokenHint,
post_logout_redirect_uri: postLogoutRedirectUri,
});
}
/**
* Generate random state for CSRF protection
*/
export function generateState() {
return generators.state();
}
/**
* Generate random nonce for ID token validation
*/
export function generateNonce() {
return generators.nonce();
}
/**
* Generate code verifier for PKCE
*/
export function generateCodeVerifier() {
return generators.codeVerifier();
}
export default {
initializeOIDC,
getClient,
getAuthorizationUrl,
exchangeCode,
getIdTokenClaims,
getUserInfo,
refreshTokens,
getEndSessionUrl,
generateState,
generateNonce,
generateCodeVerifier,
};