From 3ab7ab6a700a252d931ea132306e58a155cbf9e7 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 10 Mar 2026 22:19:01 +0100 Subject: [PATCH] feat(auth): enhance logout process to support RP-Initiated Logout for OIDC users --- server/middleware/auth.js | 2 +- server/routes/auth.js | 27 ++++++++++++++++++++++++++- server/routes/oauth.js | 10 ++++++++++ src/contexts/AuthContext.jsx | 8 +++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/server/middleware/auth.js b/server/middleware/auth.js index ef7004f..9bd5e36 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -35,7 +35,7 @@ export async function authenticateToken(req, res, next) { } 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) { return res.status(401).json({ error: 'User not found' }); } diff --git a/server/routes/auth.js b/server/routes/auth.js index 21afa75..1076da4 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -11,6 +11,7 @@ import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; +import { getOAuthConfig, discoverOIDC } from '../config/oauth.js'; import { log } from '../config/logger.js'; 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) { log.auth.error(`Logout error: ${err.message}`); res.status(500).json({ error: 'Logout failed' }); diff --git a/server/routes/oauth.js b/server/routes/oauth.js index 1248c5f..811e7de 100644 --- a/server/routes/oauth.js +++ b/server/routes/oauth.js @@ -21,6 +21,7 @@ import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { generateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; +import redis from '../config/redis.js'; import { getOAuthConfig, discoverOIDC, @@ -248,6 +249,15 @@ router.get('/callback', callbackLimiter, async (req, res) => { // Generate JWT 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. // 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'; diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 029d560..df990c7 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -42,7 +42,13 @@ export function AuthProvider({ children }) { const logout = useCallback(async () => { 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 { // ignore — token is removed locally regardless }