feat(auth): enhance logout process to support RP-Initiated Logout for OIDC users
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s

This commit is contained in:
2026-03-10 22:19:01 +01:00
parent a7b0b84f2d
commit 3ab7ab6a70
4 changed files with 44 additions and 3 deletions

View File

@@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) {
} }
const db = getDb(); const db = getDb();
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]); const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified, oauth_provider FROM users WHERE id = ?', [decoded.userId]);
if (!user) { if (!user) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: 'User not found' });
} }

View File

@@ -11,6 +11,7 @@ import { getDb } from '../config/database.js';
import redis from '../config/redis.js'; import redis from '../config/redis.js';
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -379,7 +380,31 @@ router.post('/logout', authenticateToken, async (req, res) => {
} }
} }
res.json({ message: 'Logged out successfully' }); // ── RP-Initiated Logout for OIDC/Keycloak users ──────────────────────
let keycloakLogoutUrl = null;
if (req.user.oauth_provider === 'oidc') {
try {
const config = await getOAuthConfig();
if (config) {
const oidc = await discoverOIDC(config.issuer);
if (oidc.end_session_endpoint) {
const idToken = await redis.get(`oidc:id_token:${req.user.id}`);
await redis.del(`oidc:id_token:${req.user.id}`);
const baseUrl = getBaseUrl(req);
const params = new URLSearchParams({
post_logout_redirect_uri: `${baseUrl}/`,
client_id: config.clientId,
});
if (idToken) params.set('id_token_hint', idToken);
keycloakLogoutUrl = `${oidc.end_session_endpoint}?${params.toString()}`;
}
}
} catch (oidcErr) {
log.auth.warn(`Could not build Keycloak logout URL: ${oidcErr.message}`);
}
}
res.json({ message: 'Logged out successfully', keycloakLogoutUrl });
} catch (err) { } catch (err) {
log.auth.error(`Logout error: ${err.message}`); log.auth.error(`Logout error: ${err.message}`);
res.status(500).json({ error: 'Logout failed' }); res.status(500).json({ error: 'Logout failed' });

View File

@@ -21,6 +21,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { generateToken, getBaseUrl } from '../middleware/auth.js'; import { generateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js'; import { log } from '../config/logger.js';
import redis from '../config/redis.js';
import { import {
getOAuthConfig, getOAuthConfig,
discoverOIDC, discoverOIDC,
@@ -248,6 +249,15 @@ router.get('/callback', callbackLimiter, async (req, res) => {
// Generate JWT // Generate JWT
const token = generateToken(user.id); const token = generateToken(user.id);
// Store id_token in Redis for RP-Initiated Logout (Keycloak SLO)
if (tokenResponse.id_token) {
try {
await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token);
} catch (redisErr) {
log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`);
}
}
// Redirect to frontend callback page with token. // Redirect to frontend callback page with token.
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers). // Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
const returnTo = stateData.return_to || '/dashboard'; const returnTo = stateData.return_to || '/dashboard';

View File

@@ -42,7 +42,13 @@ export function AuthProvider({ children }) {
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await api.post('/auth/logout'); const res = await api.post('/auth/logout');
localStorage.removeItem('token');
setUser(null);
if (res.data?.keycloakLogoutUrl) {
window.location.href = res.data.keycloakLogoutUrl;
return;
}
} catch { } catch {
// ignore — token is removed locally regardless // ignore — token is removed locally regardless
} }