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
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s
This commit is contained in:
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user