From 3ab7ab6a700a252d931ea132306e58a155cbf9e7 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 10 Mar 2026 22:19:01 +0100 Subject: [PATCH 1/5] 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 } From 5fc64330e0aafad62bf5980dfd4dbb78624868b7 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 10 Mar 2026 22:25:53 +0100 Subject: [PATCH 2/5] feat(migration): enhance migration script to include site settings, branding, and OAuth configuration --- migrate-from-greenlight.mjs | 296 +++++++++++++++++++++++++++++++++--- package-lock.json | 6 +- 2 files changed, 278 insertions(+), 24 deletions(-) diff --git a/migrate-from-greenlight.mjs b/migrate-from-greenlight.mjs index 34f7df8..7a3432e 100644 --- a/migrate-from-greenlight.mjs +++ b/migrate-from-greenlight.mjs @@ -1,7 +1,8 @@ /** * Greenlight → Redlight Migration Script * - * Migrates users and their rooms (including settings and shared accesses) + * Migrates users, rooms (including settings and shared accesses), + * site settings (branding, registration), logo, and OAuth/OIDC configuration * from a Greenlight v3 PostgreSQL database into a Redlight instance * (SQLite or PostgreSQL). * @@ -9,10 +10,12 @@ * node migrate-from-greenlight.mjs [options] * * Options: - * --dry-run Show what would be migrated without writing anything - * --skip-rooms Only migrate users, not rooms - * --skip-shares Do not migrate room share (shared_accesses) - * --verbose Print every imported row + * --dry-run Show what would be migrated without writing anything + * --skip-rooms Only migrate users, not rooms + * --skip-shares Do not migrate room share (shared_accesses) + * --skip-settings Do not migrate site_settings / branding + * --skip-oauth Do not migrate OAuth / OIDC configuration + * --verbose Print every imported row * * Environment variables (can also be in .env): * @@ -25,10 +28,23 @@ * SQLITE_PATH Set (or leave empty) for SQLite target * Default: ./redlight.db (relative to this script) * + * OAuth (from Greenlight .env — optional, only if --skip-oauth is NOT set): + * GL_OIDC_ISSUER OIDC issuer URL (e.g. https://keycloak.example.com/realms/myrealm) + * GL_OIDC_CLIENT_ID OIDC client ID + * GL_OIDC_CLIENT_SECRET OIDC client secret + * GL_OIDC_DISPLAY_NAME Button label on the login page (default: "SSO") + * * Password hashes: * Both Greenlight and Redlight use bcrypt, so password_digest is * copied as-is — users can log in with their existing passwords. * + * Site-settings mapping (GL site_settings → Redlight settings): + * BrandingImage → downloads file → uploads/branding/logo.* + * PrimaryColor → (logged, not mapped — Redlight uses themes) + * RegistrationMethod → registration_mode (open / invite) + * Terms → imprint_url + * PrivacyPolicy → privacy_url + * * Meeting-option → Redlight field mapping: * record → record_meeting * muteOnStart → mute_on_join @@ -42,14 +58,22 @@ import 'dotenv/config'; import pg from 'pg'; import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + // ── helpers ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); -const DRY_RUN = args.includes('--dry-run'); -const SKIP_ROOMS = args.includes('--skip-rooms'); -const SKIP_SHARES = args.includes('--skip-shares'); -const VERBOSE = args.includes('--verbose'); +const DRY_RUN = args.includes('--dry-run'); +const SKIP_ROOMS = args.includes('--skip-rooms'); +const SKIP_SHARES = args.includes('--skip-shares'); +const SKIP_SETTINGS = args.includes('--skip-settings'); +const SKIP_OAUTH = args.includes('--skip-oauth'); +const VERBOSE = args.includes('--verbose'); const c = { reset: '\x1b[0m', @@ -151,6 +175,31 @@ function boolOption(val) { return val === 'true' || val === '1' ? 1 : 0; } +// ── AES-256-GCM encryption (matching Redlight's oauth.js) ───────────────── +const ENCRYPTION_KEY = crypto + .createHash('sha256') + .update(process.env.JWT_SECRET || '') + .digest(); + +function encryptSecret(plaintext) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag().toString('hex'); + return `${iv.toString('hex')}:${authTag}:${encrypted}`; +} + +// ── Helper: upsert a setting into the Redlight settings table ───────────── +async function upsertSetting(db, key, value, isPostgres) { + const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]); + if (existing) { + await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]); + } else { + await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]); + } +} + // ── main ─────────────────────────────────────────────────────────────────── async function main() { log(); @@ -193,24 +242,25 @@ async function main() { ); info(`Found ${roles.length} roles (${adminRoleIds.size} admin role(s))`); - // ── 2. Load Greenlight users ─────────────────────────────────────────── + // ── 2. Load Greenlight users (local + OIDC) ──────────────────────────── const glUsers = await glDb.all(` - SELECT id, name, email, password_digest, language, role_id, verified, status + SELECT id, name, email, password_digest, language, role_id, verified, status, provider, external_id FROM users - WHERE provider = 'greenlight' ORDER BY created_at ASC `); - info(`Found ${glUsers.length} Greenlight user(s)`); + const localUsers = glUsers.filter(u => u.provider === 'greenlight'); + const oidcUsers = glUsers.filter(u => u.provider !== 'greenlight' && u.provider !== null); + info(`Found ${localUsers.length} local user(s), ${oidcUsers.length} OIDC/SSO user(s)`); log(); - // ── 3. Migrate users ─────────────────────────────────────────────────── - log(`${c.bold}Users${c.reset}`); + // ── 3. Migrate local users ───────────────────────────────────────────── + log(`${c.bold}Local Users${c.reset}`); const userIdMap = new Map(); // gl user id → rl user id - let usersCreated = 0, usersSkipped = 0, usersUpdated = 0; + let usersCreated = 0, usersSkipped = 0; - for (const u of glUsers) { + for (const u of localUsers) { if (!u.password_digest) { - warn(`${u.email} — no password (SSO user?), skipping`); + warn(`${u.email} — no password, skipping`); usersSkipped++; continue; } @@ -248,6 +298,59 @@ async function main() { log(` Created: ${c.green}${usersCreated}${c.reset} Skipped: ${usersSkipped}`); log(); + // ── 3b. Migrate OIDC / SSO users ─────────────────────────────────────── + log(`${c.bold}OIDC / SSO Users${c.reset}`); + let oidcCreated = 0, oidcSkipped = 0; + + for (const u of oidcUsers) { + const role = adminRoleIds.has(u.role_id) ? 'admin' : 'user'; + const lang = u.language || 'de'; + const displayName = u.name || ''; + const email = (u.email || '').toLowerCase().trim(); + if (!email) { + warn(`OIDC user id=${u.id} — no email, skipping`); + oidcSkipped++; + continue; + } + + const existing = await rlDb.get('SELECT id FROM users WHERE email = ?', [email]); + + if (existing) { + userIdMap.set(u.id, existing.id); + // Link OAuth provider if not set yet + if (!DRY_RUN && u.external_id) { + await rlDb.run( + 'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND oauth_provider IS NULL', + ['oidc', u.external_id, existing.id] + ); + } + skip(`${email} — already exists (id=${existing.id}), linked OIDC`); + oidcSkipped++; + continue; + } + + // Generate a random unusable password hash for OIDC users + const randomHash = `oauth:${crypto.randomUUID()}`; + + loud(`INSERT OIDC user ${email} (role=${role}, sub=${u.external_id || '?'})`); + if (!DRY_RUN) { + const result = await rlDb.run( + `INSERT INTO users (name, display_name, email, password_hash, role, language, email_verified, oauth_provider, oauth_provider_id) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [displayName, displayName, email, randomHash, role, lang, 'oidc', u.external_id || null] + ); + userIdMap.set(u.id, result.lastInsertRowid); + ok(`${email} (${role}, OIDC)`); + } else { + ok(`[dry] ${email} (${role}, OIDC)`); + } + oidcCreated++; + } + + log(); + log(` Created: ${c.green}${oidcCreated}${c.reset} Skipped: ${oidcSkipped}`); + log(); + if (SKIP_ROOMS) { log(`${c.yellow}--skip-rooms set, stopping here.${c.reset}`); await cleanup(glDb, rlDb); @@ -414,12 +517,163 @@ async function main() { log(); } + // ── 6. Migrate site_settings / branding ──────────────────────────────── + if (!SKIP_SETTINGS) { + log(`${c.bold}Site Settings & Branding${c.reset}`); + let settingsCount = 0; + + // Check if site_settings table exists in Greenlight + let hasSiteSettings = false; + try { + await glDb.get('SELECT 1 FROM site_settings LIMIT 1'); + hasSiteSettings = true; + } catch { + warn('No site_settings table found in Greenlight DB — skipping settings migration'); + } + + if (hasSiteSettings) { + const glSettings = await glDb.all('SELECT setting, value FROM site_settings'); + const settingsMap = new Map(glSettings.map(s => [s.setting, s.value])); + info(`Found ${glSettings.length} site_setting(s) in Greenlight`); + + // ── Registration mode ────────────────────────────────────────────── + const regMethod = settingsMap.get('RegistrationMethod'); + if (regMethod) { + // Greenlight: "open", "invite", "approval" → Redlight: "open" or "invite" + const mode = regMethod === 'open' ? 'open' : 'invite'; + if (!DRY_RUN) await upsertSetting(rlDb, 'registration_mode', mode, isPostgresTarget); + ok(`registration_mode → ${mode} (was: ${regMethod})`); + settingsCount++; + } + + // ── Privacy policy URL ───────────────────────────────────────────── + const privacy = settingsMap.get('PrivacyPolicy'); + if (privacy && privacy.trim()) { + if (!DRY_RUN) await upsertSetting(rlDb, 'privacy_url', privacy.trim(), isPostgresTarget); + ok(`privacy_url → ${privacy.trim()}`); + settingsCount++; + } + + // ── Terms / Imprint URL ──────────────────────────────────────────── + const terms = settingsMap.get('Terms'); + if (terms && terms.trim()) { + if (!DRY_RUN) await upsertSetting(rlDb, 'imprint_url', terms.trim(), isPostgresTarget); + ok(`imprint_url → ${terms.trim()}`); + settingsCount++; + } + + // ── Primary color (informational only — Redlight uses themes) ───── + const primaryColor = settingsMap.get('PrimaryColor'); + if (primaryColor) { + info(`PrimaryColor in Greenlight was: ${primaryColor} (not mapped — Redlight uses themes)`); + } + + // ── Branding image / logo ────────────────────────────────────────── + const brandingImage = settingsMap.get('BrandingImage'); + if (brandingImage && brandingImage.trim()) { + const logoUrl = brandingImage.trim(); + info(`BrandingImage URL: ${logoUrl}`); + + if (!DRY_RUN) { + try { + const response = await fetch(logoUrl, { signal: AbortSignal.timeout(15_000) }); + if (response.ok) { + const contentType = response.headers.get('content-type') || ''; + let ext = '.png'; + if (contentType.includes('svg')) ext = '.svg'; + else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg'; + else if (contentType.includes('gif')) ext = '.gif'; + else if (contentType.includes('webp')) ext = '.webp'; + else if (contentType.includes('ico')) ext = '.ico'; + + const brandingDir = path.join(__dirname, 'uploads', 'branding'); + fs.mkdirSync(brandingDir, { recursive: true }); + + // Remove old logos + if (fs.existsSync(brandingDir)) { + for (const f of fs.readdirSync(brandingDir)) { + if (f.startsWith('logo.')) fs.unlinkSync(path.join(brandingDir, f)); + } + } + + const logoPath = path.join(brandingDir, `logo${ext}`); + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(logoPath, buffer); + ok(`Logo saved → uploads/branding/logo${ext} (${(buffer.length / 1024).toFixed(1)} KB)`); + settingsCount++; + } else { + warn(`Could not download logo (HTTP ${response.status}) — skipping`); + } + } catch (dlErr) { + warn(`Logo download failed: ${dlErr.message}`); + } + } else { + ok(`[dry] Would download logo from ${logoUrl}`); + settingsCount++; + } + } + + log(); + log(` Settings migrated: ${c.green}${settingsCount}${c.reset}`); + log(); + } + } + + // ── 7. Migrate OAuth / OIDC configuration ────────────────────────────── + if (!SKIP_OAUTH) { + log(`${c.bold}OAuth / OIDC Configuration${c.reset}`); + + const issuer = process.env.GL_OIDC_ISSUER; + const clientId = process.env.GL_OIDC_CLIENT_ID; + const clientSecret = process.env.GL_OIDC_CLIENT_SECRET; + const displayName = process.env.GL_OIDC_DISPLAY_NAME || 'SSO'; + + if (issuer && clientId && clientSecret) { + if (!process.env.JWT_SECRET) { + warn('JWT_SECRET is not set — cannot encrypt client secret. Skipping OAuth migration.'); + } else { + // Check if already configured + const existing = await rlDb.get("SELECT key FROM settings WHERE key = 'oauth_config'"); + if (existing) { + warn('oauth_config already exists in Redlight — skipping (delete it first to re-migrate)'); + } else { + info(`Issuer: ${issuer}`); + info(`Client ID: ${clientId}`); + info(`Display name: ${displayName}`); + + if (!DRY_RUN) { + const config = JSON.stringify({ + issuer, + clientId, + encryptedSecret: encryptSecret(clientSecret), + displayName, + autoRegister: true, + }); + await upsertSetting(rlDb, 'oauth_config', config, isPostgresTarget); + ok('OAuth/OIDC configuration saved'); + } else { + ok('[dry] Would save OAuth/OIDC configuration'); + } + } + } + } else if (issuer || clientId || clientSecret) { + warn('Incomplete OIDC config — set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, and GL_OIDC_CLIENT_SECRET'); + } else { + info('No GL_OIDC_* env vars set — skipping OAuth migration'); + info('Set GL_OIDC_ISSUER, GL_OIDC_CLIENT_ID, GL_OIDC_CLIENT_SECRET to migrate OIDC config'); + } + log(); + } + // ── Summary ──────────────────────────────────────────────────────────── log(`${c.bold}${c.green}Migration complete${c.reset}`); log(); - log(` Users migrated: ${c.green}${usersCreated}${c.reset}`); - log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`); - if (!SKIP_SHARES) log(` Shares migrated: (see above)`); + log(` Local users migrated: ${c.green}${usersCreated}${c.reset}`); + log(` OIDC users migrated: ${c.green}${oidcCreated}${c.reset}`); + log(` Rooms migrated: ${c.green}${roomsCreated}${c.reset}`); + if (!SKIP_SHARES) log(` Shares migrated: (see above)`); + if (!SKIP_SETTINGS) log(` Settings migrated: (see above)`); + if (!SKIP_OAUTH) log(` OAuth config: (see above)`); if (DRY_RUN) { log(); log(` ${c.yellow}${c.bold}This was a DRY RUN — rerun without --dry-run to apply.${c.reset}`); diff --git a/package-lock.json b/package-lock.json index 5d794ec..9938f02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3214,9 +3214,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", From 14ed0c368909e15f449aaa361b73e330c29f4c88 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 10 Mar 2026 22:28:47 +0100 Subject: [PATCH 3/5] feat(toaster): adjust container style for improved toast positioning --- src/main.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.jsx b/src/main.jsx index b18a040..f359cbd 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -21,6 +21,7 @@ ReactDOM.createRoot(document.getElementById('root')).render( Date: Tue, 10 Mar 2026 22:41:27 +0100 Subject: [PATCH 4/5] feat(auth): improve logout process to redirect to Keycloak before clearing user state --- src/contexts/AuthContext.jsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index df990c7..e95e325 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -41,18 +41,20 @@ export function AuthProvider({ children }) { }, []); const logout = useCallback(async () => { + let keycloakLogoutUrl = null; try { const res = await api.post('/auth/logout'); - localStorage.removeItem('token'); - setUser(null); - if (res.data?.keycloakLogoutUrl) { - window.location.href = res.data.keycloakLogoutUrl; - return; - } + keycloakLogoutUrl = res.data?.keycloakLogoutUrl || null; } catch { // ignore — token is removed locally regardless } localStorage.removeItem('token'); + if (keycloakLogoutUrl) { + // Redirect to Keycloak BEFORE clearing React state to avoid + // flash-rendering the login page while the redirect is pending. + window.location.href = keycloakLogoutUrl; + return; + } setUser(null); }, []); From 71557280f51265474d0cf02c905ce9f19e955b28 Mon Sep 17 00:00:00 2001 From: Michelle Date: Tue, 10 Mar 2026 22:50:47 +0100 Subject: [PATCH 5/5] feat(sidebar): update user initials display to show first two letters of first and last name --- src/components/Sidebar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 8f810cf..b466446 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -116,7 +116,7 @@ export default function Sidebar({ open, onClose }) { className="w-full h-full object-cover" /> ) : ( - (user?.display_name || user?.name)?.[0]?.toUpperCase() || '?' + (user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?' )}