From cdfc585c8a9e8ea3465b3635147d0b10a03fbbb9 Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 4 Mar 2026 08:54:25 +0100 Subject: [PATCH] feat: implement OAuth 2.0 / OpenID Connect support - Added OAuth configuration management in the admin panel. - Implemented OAuth authorization flow with PKCE for enhanced security. - Created routes for handling OAuth provider discovery, authorization, and callback. - Integrated OAuth login and registration options in the frontend. - Updated UI components to support OAuth login and registration. - Added internationalization strings for OAuth-related messages. - Implemented encryption for client secrets and secure state management. - Added error handling and user feedback for OAuth processes. --- server/config/database.js | 33 +++++ server/config/oauth.js | 275 +++++++++++++++++++++++++++++++++++ server/index.js | 2 + server/routes/admin.js | 102 +++++++++++++ server/routes/branding.js | 14 ++ server/routes/oauth.js | 261 +++++++++++++++++++++++++++++++++ src/App.jsx | 2 + src/contexts/AuthContext.jsx | 14 +- src/i18n/de.json | 31 +++- src/i18n/en.json | 31 +++- src/pages/Admin.jsx | 161 +++++++++++++++++++- src/pages/Login.jsx | 24 ++- src/pages/OAuthCallback.jsx | 75 ++++++++++ src/pages/Register.jsx | 24 ++- 14 files changed, 1039 insertions(+), 10 deletions(-) create mode 100644 server/config/oauth.js create mode 100644 server/routes/oauth.js create mode 100644 src/pages/OAuthCallback.jsx diff --git a/server/config/database.js b/server/config/database.js index 0222a26..1cb48b4 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -679,6 +679,39 @@ export async function initDatabase() { `); } + // ── OAuth tables ──────────────────────────────────────────────────────── + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + provider TEXT NOT NULL, + code_verifier TEXT NOT NULL, + return_to TEXT, + expires_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS oauth_states ( + state TEXT PRIMARY KEY, + provider TEXT NOT NULL, + code_verifier TEXT NOT NULL, + return_to TEXT, + expires_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON oauth_states(expires_at); + `); + } + + // Add OAuth columns to users table + if (!(await db.columnExists('users', 'oauth_provider'))) { + await db.exec('ALTER TABLE users ADD COLUMN oauth_provider TEXT DEFAULT NULL'); + } + if (!(await db.columnExists('users', 'oauth_provider_id'))) { + await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL'); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/config/oauth.js b/server/config/oauth.js new file mode 100644 index 0000000..2bafcb2 --- /dev/null +++ b/server/config/oauth.js @@ -0,0 +1,275 @@ +/** + * OAuth / OpenID Connect configuration for Redlight. + * + * Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.) + * configured at runtime via admin settings stored in the database. + * + * Security: + * - PKCE (S256) on every authorization request + * - Anti-CSRF via cryptographic `state` parameter stored server-side + * - State entries expire after 10 minutes and are single-use + * - Client secrets are stored AES-256-GCM encrypted in the DB + * - Only https callback URLs in production + * - Token exchange uses server-side secret, never exposed to the browser + */ + +import crypto from 'crypto'; +import { getDb } from './database.js'; +import { log } from './logger.js'; + +// ── Encryption helpers for client secrets ────────────────────────────────── +// Derive a key from JWT_SECRET (always available) +const ENCRYPTION_KEY = crypto + .createHash('sha256') + .update(process.env.JWT_SECRET || '') + .digest(); // 32 bytes → AES-256 + +/** + * Encrypt a plaintext string with AES-256-GCM. + * Returns "iv:authTag:ciphertext" (all hex-encoded). + */ +export 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}`; +} + +/** + * Decrypt an AES-256-GCM encrypted string. + */ +export function decryptSecret(encryptedStr) { + const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +// ── PKCE helpers ─────────────────────────────────────────────────────────── + +/** Generate a cryptographically random code_verifier (RFC 7636). */ +export function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url'); +} + +/** Compute the S256 code_challenge from a code_verifier. */ +export function computeCodeChallenge(verifier) { + return crypto.createHash('sha256').update(verifier).digest('base64url'); +} + +// ── State management (anti-CSRF) ─────────────────────────────────────────── + +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** + * Create and persist an OAuth state token with associated PKCE verifier. + * @param {string} provider – provider key (e.g. 'oidc') + * @param {string} codeVerifier – PKCE code_verifier + * @param {string|null} returnTo – optional return URL after login + * @returns {Promise} state token + */ +export async function createOAuthState(provider, codeVerifier, returnTo = null) { + const state = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); + const db = getDb(); + await db.run( + 'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)', + [state, provider, codeVerifier, returnTo, expiresAt], + ); + return state; +} + +/** + * Consume (validate + delete) an OAuth state token. + * Returns the stored data or null if invalid/expired. + * @param {string} state + * @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>} + */ +export async function consumeOAuthState(state) { + if (!state || typeof state !== 'string' || state.length > 128) return null; + const db = getDb(); + const row = await db.get( + 'SELECT * FROM oauth_states WHERE state = ?', + [state], + ); + if (!row) return null; + + // Always delete (single-use) + await db.run('DELETE FROM oauth_states WHERE state = ?', [state]); + + // Check expiry + if (new Date(row.expires_at) < new Date()) return null; + + return { + provider: row.provider, + code_verifier: row.code_verifier, + return_to: row.return_to, + }; +} + +/** + * Garbage-collect expired OAuth states (called periodically). + */ +export async function cleanupExpiredStates() { + try { + const db = getDb(); + await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP'); + } catch (err) { + log.auth.warn(`OAuth state cleanup failed: ${err.message}`); + } +} + +// ── Provider configuration ───────────────────────────────────────────────── + +/** + * Load the stored OAuth provider config from the settings table. + * Returns null if OAuth is not configured. + * @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>} + */ +export async function getOAuthConfig() { + try { + const db = getDb(); + const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'"); + if (!row?.value) return null; + + const config = JSON.parse(row.value); + if (!config.issuer || !config.clientId || !config.encryptedSecret) return null; + + return { + issuer: config.issuer, + clientId: config.clientId, + clientSecret: decryptSecret(config.encryptedSecret), + displayName: config.displayName || 'SSO', + autoRegister: config.autoRegister !== false, + }; + } catch (err) { + log.auth.error(`Failed to load OAuth config: ${err.message}`); + return null; + } +} + +/** + * Save OAuth provider config to the settings table. + * The client secret is encrypted before storage. + */ +export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) { + const db = getDb(); + const config = { + issuer, + clientId, + encryptedSecret: encryptSecret(clientSecret), + displayName: displayName || 'SSO', + autoRegister: autoRegister !== false, + }; + const value = JSON.stringify(config); + + const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'"); + if (existing) { + await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]); + } else { + await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]); + } +} + +/** + * Remove OAuth configuration. + */ +export async function deleteOAuthConfig() { + const db = getDb(); + await db.run("DELETE FROM settings WHERE key = 'oauth_config'"); +} + +// ── OIDC Discovery ───────────────────────────────────────────────────────── + +// Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... } +const discoveryCache = new Map(); +const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Fetch and cache the OpenID Connect discovery document for the given issuer. + * @param {string} issuer + * @returns {Promise} + */ +export async function discoverOIDC(issuer) { + const cached = discoveryCache.get(issuer); + if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) { + return cached.data; + } + + // Normalize issuer URL + const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer; + const url = `${base}/.well-known/openid-configuration`; + + const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!response.ok) { + throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`); + } + + const data = await response.json(); + if (!data.authorization_endpoint || !data.token_endpoint) { + throw new Error(`OIDC discovery response missing required endpoints`); + } + + discoveryCache.set(issuer, { data, fetchedAt: Date.now() }); + return data; +} + +/** + * Exchange an authorization code for tokens. + * @param {object} oidcConfig – discovery document + * @param {string} code + * @param {string} redirectUri + * @param {string} clientId + * @param {string} clientSecret + * @param {string} codeVerifier – PKCE verifier + * @returns {Promise} token response + */ +export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + code_verifier: codeVerifier, + }); + + const response = await fetch(oidcConfig.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + throw new Error(`Token exchange failed: HTTP ${response.status} – ${errText.slice(0, 200)}`); + } + + return response.json(); +} + +/** + * Fetch user info from the provider's userinfo endpoint. + * @param {string} userInfoUrl + * @param {string} accessToken + * @returns {Promise} + */ +export async function fetchUserInfo(userInfoUrl, accessToken) { + const response = await fetch(userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + throw new Error(`UserInfo fetch failed: HTTP ${response.status}`); + } + + return response.json(); +} diff --git a/server/index.js b/server/index.js index cdc4c4f..f3bdd8e 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import federationRoutes, { wellKnownHandler } from './routes/federation.js'; import calendarRoutes from './routes/calendar.js'; import caldavRoutes from './routes/caldav.js'; import notificationRoutes from './routes/notifications.js'; +import oauthRoutes from './routes/oauth.js'; import { startFederationSync } from './jobs/federationSync.js'; const __filename = fileURLToPath(import.meta.url); @@ -71,6 +72,7 @@ async function start() { app.use('/api/federation', federationRoutes); app.use('/api/calendar', calendarRoutes); app.use('/api/notifications', notificationRoutes); + app.use('/api/oauth', oauthRoutes); // CalDAV — mounted outside /api so calendar clients use a clean path app.use('/caldav', caldavRoutes); // Mount calendar federation receive also under /api/federation for remote instances diff --git a/server/routes/admin.js b/server/routes/admin.js index cca6eec..4ead484 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -5,6 +5,12 @@ import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; +import { + getOAuthConfig, + saveOAuthConfig, + deleteOAuthConfig, + discoverOIDC, +} from '../config/oauth.js'; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; @@ -260,4 +266,100 @@ router.delete('/invites/:id', authenticateToken, requireAdmin, async (req, res) } }); +// ── OAuth / SSO Configuration (admin only) ────────────────────────────────── + +// GET /api/admin/oauth - Get current OAuth configuration +router.get('/oauth', authenticateToken, requireAdmin, async (req, res) => { + try { + const config = await getOAuthConfig(); + if (!config) { + return res.json({ configured: false, config: null }); + } + // Never expose the decrypted client secret to the frontend + res.json({ + configured: true, + config: { + issuer: config.issuer, + clientId: config.clientId, + hasClientSecret: !!config.clientSecret, + displayName: config.displayName || 'SSO', + autoRegister: config.autoRegister ?? true, + }, + }); + } catch (err) { + log.admin.error(`Get OAuth config error: ${err.message}`); + res.status(500).json({ error: 'Could not load OAuth configuration' }); + } +}); + +// PUT /api/admin/oauth - Save OAuth configuration +router.put('/oauth', authenticateToken, requireAdmin, async (req, res) => { + try { + const { issuer, clientId, clientSecret, displayName, autoRegister } = req.body; + + if (!issuer || !clientId) { + return res.status(400).json({ error: 'Issuer URL and Client ID are required' }); + } + + // Validate issuer URL + try { + const parsed = new URL(issuer); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return res.status(400).json({ error: 'Issuer URL must use https:// (or http:// for development)' }); + } + } catch { + return res.status(400).json({ error: 'Invalid Issuer URL' }); + } + + // Validate display name length + if (displayName && displayName.length > 50) { + return res.status(400).json({ error: 'Display name must not exceed 50 characters' }); + } + + // Check if the existing config has a secret and none is being sent (keep old one) + let finalSecret = clientSecret; + if (!clientSecret) { + const existing = await getOAuthConfig(); + if (existing?.clientSecret) { + finalSecret = existing.clientSecret; + } + } + + // Attempt OIDC discovery to validate the issuer endpoint + try { + await discoverOIDC(issuer); + } catch (discErr) { + return res.status(400).json({ + error: `Could not discover OIDC configuration at ${issuer}: ${discErr.message}`, + }); + } + + await saveOAuthConfig({ + issuer, + clientId, + clientSecret: finalSecret || '', + displayName: displayName || 'SSO', + autoRegister: autoRegister !== false, + }); + + log.admin.info(`OAuth configuration saved by admin (issuer: ${issuer})`); + res.json({ message: 'OAuth configuration saved' }); + } catch (err) { + log.admin.error(`Save OAuth config error: ${err.message}`); + res.status(500).json({ error: 'Could not save OAuth configuration' }); + } +}); + +// DELETE /api/admin/oauth - Remove OAuth configuration +router.delete('/oauth', authenticateToken, requireAdmin, async (req, res) => { + try { + await deleteOAuthConfig(); + log.admin.info('OAuth configuration removed by admin'); + res.json({ message: 'OAuth configuration removed' }); + } catch (err) { + log.admin.error(`Delete OAuth config error: ${err.message}`); + res.status(500).json({ error: 'Could not remove OAuth configuration' }); + } +}); + export default router; diff --git a/server/routes/branding.js b/server/routes/branding.js index 9281735..5bd3223 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { getDb } from '../config/database.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; import { log } from '../config/logger.js'; +import { getOAuthConfig } from '../config/oauth.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -96,6 +97,17 @@ router.get('/', async (req, res) => { const imprintUrl = await getSetting('imprint_url'); const privacyUrl = await getSetting('privacy_url'); + // OAuth: expose whether OAuth is enabled + display name for login page + let oauthEnabled = false; + let oauthDisplayName = null; + try { + const oauthConfig = await getOAuthConfig(); + if (oauthConfig) { + oauthEnabled = true; + oauthDisplayName = oauthConfig.displayName || 'SSO'; + } + } catch { /* not configured */ } + res.json({ appName: appName || 'Redlight', hasLogo: !!logoFile, @@ -104,6 +116,8 @@ router.get('/', async (req, res) => { registrationMode: registrationMode || 'open', imprintUrl: imprintUrl || null, privacyUrl: privacyUrl || null, + oauthEnabled, + oauthDisplayName, }); } catch (err) { log.branding.error('Get branding error:', err); diff --git a/server/routes/oauth.js b/server/routes/oauth.js new file mode 100644 index 0000000..f28caf4 --- /dev/null +++ b/server/routes/oauth.js @@ -0,0 +1,261 @@ +/** + * OAuth / OpenID Connect routes for Redlight. + * + * Flow: + * 1. Client calls GET /api/oauth/providers → returns enabled provider info + * 2. Client calls GET /api/oauth/authorize → server generates PKCE + state, redirects to IdP + * 3. IdP redirects to GET /api/oauth/callback with code + state + * 4. Server exchanges code for tokens, fetches user info, creates/links account, returns JWT + * + * Security: + * - PKCE (S256) everywhere + * - Cryptographic state token (single-use, 10-min TTL) + * - Client secret never leaves the server + * - OAuth user info validated and sanitized before DB insertion + * - Rate limited callback endpoint + */ + +import { Router } from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { v4 as uuidv4 } from 'uuid'; +import { getDb } from '../config/database.js'; +import { generateToken } from '../middleware/auth.js'; +import { log } from '../config/logger.js'; +import { + getOAuthConfig, + discoverOIDC, + createOAuthState, + consumeOAuthState, + generateCodeVerifier, + computeCodeChallenge, + exchangeCode, + fetchUserInfo, +} from '../config/oauth.js'; + +const router = Router(); + +// Rate limit the callback to prevent brute-force of authorization codes +const callbackLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many OAuth attempts. Please try again later.' }, +}); + +// ── GET /api/oauth/providers — List available OAuth providers (public) ─────── +router.get('/providers', async (req, res) => { + try { + const config = await getOAuthConfig(); + if (!config) { + return res.json({ providers: [] }); + } + + // Never expose client secret or issuer details to the client + res.json({ + providers: [ + { + id: 'oidc', + name: config.displayName || 'SSO', + }, + ], + }); + } catch (err) { + log.auth.error(`OAuth providers error: ${err.message}`); + res.json({ providers: [] }); + } +}); + +// ── GET /api/oauth/authorize — Start OAuth flow (redirects to IdP) ────────── +router.get('/authorize', async (req, res) => { + try { + const config = await getOAuthConfig(); + if (!config) { + return res.status(400).json({ error: 'OAuth is not configured' }); + } + + // Discover OIDC endpoints + const oidc = await discoverOIDC(config.issuer); + + // Generate PKCE pair + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeCodeChallenge(codeVerifier); + + // Optional return_to from query (validate it's a relative path) + let returnTo = req.query.return_to || null; + if (returnTo && (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//'))) { + returnTo = null; // prevent open redirect + } + + // Create server-side state + const state = await createOAuthState('oidc', codeVerifier, returnTo); + + // Build callback URL + const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const redirectUri = `${baseUrl}/api/oauth/callback`; + + // Build authorization URL + const params = new URLSearchParams({ + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectUri, + scope: 'openid email profile', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + + const authUrl = `${oidc.authorization_endpoint}?${params.toString()}`; + res.redirect(authUrl); + } catch (err) { + log.auth.error(`OAuth authorize error: ${err.message}`); + res.status(500).json({ error: 'Could not start OAuth flow' }); + } +}); + +// ── GET /api/oauth/callback — Handle IdP callback ────────────────────────── +router.get('/callback', callbackLimiter, async (req, res) => { + try { + const { code, state, error: oauthError, error_description } = req.query; + + // Build frontend error redirect helper + const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const errorRedirect = (msg) => + res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`); + + // Handle IdP errors + if (oauthError) { + log.auth.warn(`OAuth IdP error: ${oauthError} – ${error_description || ''}`); + return errorRedirect(error_description || oauthError); + } + + if (!code || !state) { + return errorRedirect('Missing authorization code or state'); + } + + // Validate and consume state (CSRF + PKCE verifier retrieval) + const stateData = await consumeOAuthState(state); + if (!stateData) { + return errorRedirect('Invalid or expired OAuth state. Please try again.'); + } + + // Load provider config + const config = await getOAuthConfig(); + if (!config) { + return errorRedirect('OAuth is not configured'); + } + + // Discover OIDC endpoints + const oidc = await discoverOIDC(config.issuer); + + // Exchange code for tokens + const redirectUri = `${baseUrl}/api/oauth/callback`; + const tokenResponse = await exchangeCode( + oidc, code, redirectUri, config.clientId, config.clientSecret, stateData.code_verifier, + ); + + if (!tokenResponse.access_token) { + return errorRedirect('Token exchange failed: no access token received'); + } + + // Fetch user info from the IdP + let userInfo; + if (oidc.userinfo_endpoint) { + userInfo = await fetchUserInfo(oidc.userinfo_endpoint, tokenResponse.access_token); + } else { + return errorRedirect('Provider does not support userinfo endpoint'); + } + + // Extract and validate user attributes + const email = (userInfo.email || '').toLowerCase().trim(); + const sub = userInfo.sub || ''; + const name = userInfo.preferred_username || userInfo.name || email.split('@')[0] || ''; + const displayName = userInfo.name || userInfo.preferred_username || ''; + + if (!email || !sub) { + return errorRedirect('OAuth provider did not return an email or subject'); + } + + // Basic email validation + const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; + if (!EMAIL_RE.test(email)) { + return errorRedirect('OAuth provider returned an invalid email address'); + } + + const db = getDb(); + + // ── Link or create user ────────────────────────────────────────────── + // 1. Check if there's already a user linked with this OAuth provider + sub + let user = await db.get( + 'SELECT id, name, display_name, email, role, email_verified FROM users WHERE oauth_provider = ? AND oauth_provider_id = ?', + ['oidc', sub], + ); + + if (!user) { + // 2. Check if a user with this email already exists (link accounts) + user = await db.get( + 'SELECT id, name, display_name, email, role, email_verified, oauth_provider FROM users WHERE email = ?', + [email], + ); + + if (user) { + // Link OAuth to existing account + await db.run( + 'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + ['oidc', sub, user.id], + ); + log.auth.info(`Linked OAuth (oidc/${sub}) to existing user ${user.email}`); + } else { + // 3. Auto-register new user (if enabled) + if (!config.autoRegister) { + return errorRedirect('No account found for this email. Please register first or ask an admin.'); + } + + // Check registration mode + const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'"); + const registrationMode = regModeSetting?.value || 'open'; + if (registrationMode === 'invite') { + return errorRedirect('Registration is invite-only. An administrator must create your account first.'); + } + + // Sanitize username: only allow safe characters + const safeUsername = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30) || `user_${Date.now()}`; + + // Ensure username is unique + let finalUsername = safeUsername; + const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [safeUsername]); + if (existingUsername) { + finalUsername = `${safeUsername}_${Date.now().toString(36)}`; + } + + // No password needed for OAuth users — use a random hash that can't be guessed + const randomPasswordHash = `oauth:${uuidv4()}`; + + const result = await db.run( + `INSERT INTO users (name, display_name, email, password_hash, email_verified, oauth_provider, oauth_provider_id) + VALUES (?, ?, ?, ?, 1, ?, ?)`, + [finalUsername, displayName.slice(0, 100) || finalUsername, email, randomPasswordHash, 'oidc', sub], + ); + + user = await db.get( + 'SELECT id, name, display_name, email, role, email_verified FROM users WHERE id = ?', + [result.lastInsertRowid], + ); + log.auth.info(`Created new OAuth user: ${email} (oidc/${sub})`); + } + } + + // Generate JWT + const token = generateToken(user.id); + + // Redirect to frontend callback page with token + const returnTo = stateData.return_to || '/dashboard'; + res.redirect(`${baseUrl}/oauth/callback?token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`); + } catch (err) { + log.auth.error(`OAuth callback error: ${err.message}`); + const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`); + } +}); + +export default router; diff --git a/src/App.jsx b/src/App.jsx index 1552f3d..6810bb8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,6 +17,7 @@ import GuestJoin from './pages/GuestJoin'; import FederationInbox from './pages/FederationInbox'; import FederatedRoomDetail from './pages/FederatedRoomDetail'; import Calendar from './pages/Calendar'; +import OAuthCallback from './pages/OAuthCallback'; export default function App() { const { user, loading } = useAuth(); @@ -50,6 +51,7 @@ export default function App() { : } /> : } /> } /> + } /> } /> {/* Protected routes */} diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 41fe1c0..029d560 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -50,12 +50,24 @@ export function AuthProvider({ children }) { setUser(null); }, []); + const loginWithOAuth = useCallback(async (token) => { + localStorage.setItem('token', token); + try { + const res = await api.get('/auth/me'); + setUser(res.data.user); + return res.data.user; + } catch (err) { + localStorage.removeItem('token'); + throw err; + } + }, []); + const updateUser = useCallback((updatedUser) => { setUser(updatedUser); }, []); return ( - + {children} ); diff --git a/src/i18n/de.json b/src/i18n/de.json index 54ccc0c..cc0bcee 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -91,7 +91,15 @@ "emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!", "emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden", "inviteOnly": "Nur mit Einladung", - "inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen." + "inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.", + "orContinueWith": "oder weiter mit", + "loginWithOAuth": "Anmelden mit {provider}", + "registerWithOAuth": "Registrieren mit {provider}", + "backToLogin": "Zurück zum Login", + "oauthError": "Anmeldung fehlgeschlagen", + "oauthNoToken": "Kein Authentifizierungstoken erhalten.", + "oauthLoginFailed": "Anmeldung konnte nicht abgeschlossen werden. Bitte versuche es erneut.", + "oauthRedirecting": "Du wirst angemeldet..." }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -395,7 +403,26 @@ "imprintUrlSaved": "Impressum-URL gespeichert", "privacyUrlSaved": "Datenschutz-URL gespeichert", "imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden", - "privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden" + "privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden", + "oauthTitle": "OAuth / SSO", + "oauthDescription": "OpenID-Connect-Anbieter verbinden (z. B. Keycloak, Authentik, Google) für Single Sign-On.", + "oauthIssuer": "Issuer-URL", + "oauthIssuerHint": "Die OIDC-Issuer-URL, z. B. https://auth.example.com/realms/main", + "oauthClientId": "Client-ID", + "oauthClientSecret": "Client-Secret", + "oauthClientSecretHint": "Leer lassen, um das bestehende Secret beizubehalten", + "oauthDisplayName": "Button-Beschriftung", + "oauthDisplayNameHint": "Wird auf der Login-Seite angezeigt, z. B. „Firmen-SSO"", + "oauthAutoRegister": "Neue Benutzer automatisch registrieren", + "oauthAutoRegisterHint": "Erstellt automatisch Konten für Benutzer, die sich zum ersten Mal per OAuth anmelden.", + "oauthSaved": "OAuth-Konfiguration gespeichert", + "oauthSaveFailed": "OAuth-Konfiguration konnte nicht gespeichert werden", + "oauthRemoved": "OAuth-Konfiguration entfernt", + "oauthRemoveFailed": "OAuth-Konfiguration konnte nicht entfernt werden", + "oauthRemoveConfirm": "OAuth-Konfiguration wirklich entfernen? Benutzer können sich dann nicht mehr per SSO anmelden.", + "oauthNotConfigured": "OAuth ist noch nicht konfiguriert.", + "oauthSave": "OAuth speichern", + "oauthRemove": "OAuth entfernen" }, "notifications": { "bell": "Benachrichtigungen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 2ffdeae..56ea247 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -91,7 +91,15 @@ "emailVerificationResendSuccess": "Verification email sent!", "emailVerificationResendFailed": "Could not send verification email", "inviteOnly": "Invite Only", - "inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account." + "inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.", + "orContinueWith": "or continue with", + "loginWithOAuth": "Sign in with {provider}", + "registerWithOAuth": "Sign up with {provider}", + "backToLogin": "Back to login", + "oauthError": "Authentication failed", + "oauthNoToken": "No authentication token received.", + "oauthLoginFailed": "Could not complete sign in. Please try again.", + "oauthRedirecting": "Signing you in..." }, "home": { "poweredBy": "Powered by BigBlueButton", @@ -395,7 +403,26 @@ "imprintUrlSaved": "Imprint URL saved", "privacyUrlSaved": "Privacy Policy URL saved", "imprintUrlFailed": "Could not save Imprint URL", - "privacyUrlFailed": "Could not save Privacy Policy URL" + "privacyUrlFailed": "Could not save Privacy Policy URL", + "oauthTitle": "OAuth / SSO", + "oauthDescription": "Connect an OpenID Connect provider (e.g. Keycloak, Authentik, Google) to allow Single Sign-On.", + "oauthIssuer": "Issuer URL", + "oauthIssuerHint": "The OIDC issuer URL, e.g. https://auth.example.com/realms/main", + "oauthClientId": "Client ID", + "oauthClientSecret": "Client Secret", + "oauthClientSecretHint": "Leave blank to keep the existing secret", + "oauthDisplayName": "Button label", + "oauthDisplayNameHint": "Shown on the login page, e.g. \"Company SSO\"", + "oauthAutoRegister": "Auto-register new users", + "oauthAutoRegisterHint": "Automatically create accounts for users signing in via OAuth for the first time.", + "oauthSaved": "OAuth configuration saved", + "oauthSaveFailed": "Could not save OAuth configuration", + "oauthRemoved": "OAuth configuration removed", + "oauthRemoveFailed": "Could not remove OAuth configuration", + "oauthRemoveConfirm": "Really remove OAuth configuration? Users will no longer be able to sign in with SSO.", + "oauthNotConfigured": "OAuth is not configured yet.", + "oauthSave": "Save OAuth", + "oauthRemove": "Remove OAuth" }, "notifications": { "bell": "Notifications", diff --git a/src/pages/Admin.jsx b/src/pages/Admin.jsx index fe451d7..ff58771 100644 --- a/src/pages/Admin.jsx +++ b/src/pages/Admin.jsx @@ -4,7 +4,7 @@ import { Users, Shield, Search, Trash2, ChevronDown, Loader2, MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User, Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check, - ShieldCheck, Globe, Link as LinkIcon, + ShieldCheck, Globe, Link as LinkIcon, LogIn, } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; @@ -48,6 +48,12 @@ export default function Admin() { const [editPrivacyUrl, setEditPrivacyUrl] = useState(''); const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false); + // OAuth state + const [oauthConfig, setOauthConfig] = useState(null); + const [oauthLoading, setOauthLoading] = useState(true); + const [oauthForm, setOauthForm] = useState({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true }); + const [savingOauth, setSavingOauth] = useState(false); + useEffect(() => { if (user?.role !== 'admin') { navigate('/dashboard'); @@ -55,6 +61,7 @@ export default function Admin() { } fetchUsers(); fetchInvites(); + fetchOauthConfig(); }, [user]); useEffect(() => { @@ -275,6 +282,58 @@ export default function Admin() { } }; + // ── OAuth handlers ────────────────────────────────────────────────────── + const fetchOauthConfig = async () => { + setOauthLoading(true); + try { + const res = await api.get('/admin/oauth'); + if (res.data.configured) { + setOauthConfig(res.data.config); + setOauthForm({ + issuer: res.data.config.issuer || '', + clientId: res.data.config.clientId || '', + clientSecret: '', + displayName: res.data.config.displayName || 'SSO', + autoRegister: res.data.config.autoRegister ?? true, + }); + } else { + setOauthConfig(null); + } + } catch { + // silently fail + } finally { + setOauthLoading(false); + } + }; + + const handleOauthSave = async (e) => { + e.preventDefault(); + setSavingOauth(true); + try { + await api.put('/admin/oauth', oauthForm); + toast.success(t('admin.oauthSaved')); + fetchOauthConfig(); + refreshBranding(); + } catch (err) { + toast.error(err.response?.data?.error || t('admin.oauthSaveFailed')); + } finally { + setSavingOauth(false); + } + }; + + const handleOauthRemove = async () => { + if (!confirm(t('admin.oauthRemoveConfirm'))) return; + try { + await api.delete('/admin/oauth'); + toast.success(t('admin.oauthRemoved')); + setOauthConfig(null); + setOauthForm({ issuer: '', clientId: '', clientSecret: '', displayName: 'SSO', autoRegister: true }); + refreshBranding(); + } catch { + toast.error(t('admin.oauthRemoveFailed')); + } + }; + const filteredUsers = users.filter(u => (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase()) @@ -596,6 +655,106 @@ export default function Admin() { )} + {/* OAuth / SSO Configuration */} +
+
+ +

{t('admin.oauthTitle')}

+
+

{t('admin.oauthDescription')}

+ + {oauthLoading ? ( +
+ +
+ ) : ( +
+
+
+ + setOauthForm(f => ({ ...f, issuer: e.target.value }))} + className="input-field text-sm" + placeholder="https://auth.example.com/realms/main" + required + /> +

{t('admin.oauthIssuerHint')}

+
+
+ + setOauthForm(f => ({ ...f, clientId: e.target.value }))} + className="input-field text-sm" + placeholder="redlight" + required + /> +
+
+ +
+
+ + setOauthForm(f => ({ ...f, clientSecret: e.target.value }))} + className="input-field text-sm" + placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''} + /> + {oauthConfig?.hasClientSecret && ( +

{t('admin.oauthClientSecretHint')}

+ )} +
+
+ + setOauthForm(f => ({ ...f, displayName: e.target.value }))} + className="input-field text-sm" + placeholder="Company SSO" + maxLength={50} + /> +

{t('admin.oauthDisplayNameHint')}

+
+
+ +
+
+ {/* Search */}
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 9dc9221..4654daf 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useBranding } from '../contexts/BrandingContext'; -import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'; +import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw, LogIn } from 'lucide-react'; import BrandLogo from '../components/BrandLogo'; import api from '../services/api'; import toast from 'react-hot-toast'; @@ -17,7 +17,7 @@ export default function Login() { const [resending, setResending] = useState(false); const { login } = useAuth(); const { t } = useLanguage(); - const { registrationMode } = useBranding(); + const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding(); const navigate = useNavigate(); useEffect(() => { @@ -135,6 +135,26 @@ export default function Login() { + {oauthEnabled && ( + <> +
+
+
+
+
+ {t('auth.orContinueWith')} +
+
+ + + {t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')} + + + )} + {needsVerification && (
diff --git a/src/pages/OAuthCallback.jsx b/src/pages/OAuthCallback.jsx new file mode 100644 index 0000000..c7c344b --- /dev/null +++ b/src/pages/OAuthCallback.jsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; +import { useLanguage } from '../contexts/LanguageContext'; +import { Loader2, AlertTriangle } from 'lucide-react'; +import toast from 'react-hot-toast'; + +export default function OAuthCallback() { + const [searchParams] = useSearchParams(); + const [error, setError] = useState(null); + const { loginWithOAuth } = useAuth(); + const { t } = useLanguage(); + const navigate = useNavigate(); + + useEffect(() => { + const token = searchParams.get('token'); + const errorMsg = searchParams.get('error'); + const returnTo = searchParams.get('return_to') || '/dashboard'; + + if (errorMsg) { + setError(errorMsg); + return; + } + + if (!token) { + setError(t('auth.oauthNoToken')); + return; + } + + // Store token and redirect + loginWithOAuth(token) + .then(() => { + toast.success(t('auth.loginSuccess')); + navigate(returnTo, { replace: true }); + }) + .catch(() => { + setError(t('auth.oauthLoginFailed')); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return ( +
+
+
+
+
+
+ +
+
+

{t('auth.oauthError')}

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+ +

{t('auth.oauthRedirecting')}

+
+
+ ); +} diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx index 731098b..e00197e 100644 --- a/src/pages/Register.jsx +++ b/src/pages/Register.jsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import { useBranding } from '../contexts/BrandingContext'; -import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert } from 'lucide-react'; +import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle, ShieldAlert, LogIn } from 'lucide-react'; import BrandLogo from '../components/BrandLogo'; import toast from 'react-hot-toast'; @@ -19,7 +19,7 @@ export default function Register() { const [needsVerification, setNeedsVerification] = useState(false); const { register } = useAuth(); const { t } = useLanguage(); - const { registrationMode } = useBranding(); + const { registrationMode, oauthEnabled, oauthDisplayName } = useBranding(); const navigate = useNavigate(); // Invite-only mode without a token → show blocked message @@ -197,6 +197,26 @@ export default function Register() { + {oauthEnabled && ( + <> +
+
+
+
+
+ {t('auth.orContinueWith')} +
+
+ + + {t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')} + + + )} +

{t('auth.hasAccount')}{' '}