feat: implement OAuth 2.0 / OpenID Connect support
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m12s

- 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.
This commit is contained in:
2026-03-04 08:54:25 +01:00
parent e22a895672
commit cdfc585c8a
14 changed files with 1039 additions and 10 deletions

View File

@@ -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) {

275
server/config/oauth.js Normal file
View File

@@ -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<string>} 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<object>}
*/
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<object>} 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<object>}
*/
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();
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

261
server/routes/oauth.js Normal file
View File

@@ -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;

View File

@@ -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() {
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */}

View File

@@ -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 (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
<AuthContext.Provider value={{ user, loading, login, register, logout, loginWithOAuth, updateUser }}>
{children}
</AuthContext.Provider>
);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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() {
)}
</div>
{/* OAuth / SSO Configuration */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<LogIn size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.oauthTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.oauthDescription')}</p>
{oauthLoading ? (
<div className="flex justify-center py-4">
<Loader2 size={20} className="animate-spin text-th-accent" />
</div>
) : (
<form onSubmit={handleOauthSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<input
type="url"
value={oauthForm.issuer}
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
className="input-field text-sm"
placeholder="https://auth.example.com/realms/main"
required
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<input
type="text"
value={oauthForm.clientId}
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
className="input-field text-sm"
placeholder="redlight"
required
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<input
type="password"
value={oauthForm.clientSecret}
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
className="input-field text-sm"
placeholder={oauthConfig?.hasClientSecret ? '••••••••' : ''}
/>
{oauthConfig?.hasClientSecret && (
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthClientSecretHint')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<input
type="text"
value={oauthForm.displayName}
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
className="input-field text-sm"
placeholder="Company SSO"
maxLength={50}
/>
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthDisplayNameHint')}</p>
</div>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={oauthForm.autoRegister}
onChange={e => setOauthForm(f => ({ ...f, autoRegister: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-th-border rounded-full peer peer-checked:bg-th-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
</label>
<div>
<span className="text-sm font-medium text-th-text">{t('admin.oauthAutoRegister')}</span>
<p className="text-xs text-th-text-s">{t('admin.oauthAutoRegisterHint')}</p>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button type="submit" disabled={savingOauth} className="btn-primary text-sm px-5">
{savingOauth ? <Loader2 size={14} className="animate-spin" /> : null}
{t('admin.oauthSave')}
</button>
{oauthConfig && (
<button type="button" onClick={handleOauthRemove} className="btn-secondary text-sm px-5 text-red-400 hover:text-red-300">
<Trash2 size={14} />
{t('admin.oauthRemove')}
</button>
)}
</div>
</form>
)}
</div>
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">

View File

@@ -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() {
</button>
</form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.loginWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center">
<AlertTriangle size={24} className="text-red-400" />
</div>
</div>
<h2 className="text-xl font-bold text-th-text mb-2">{t('auth.oauthError')}</h2>
<p className="text-th-text-s mb-6">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full py-3"
>
{t('auth.backToLogin')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 bg-th-bg" />
<div className="relative flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-th-accent" />
<p className="text-th-text-s">{t('auth.oauthRedirecting')}</p>
</div>
</div>
);
}

View File

@@ -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() {
</button>
</form>
{oauthEnabled && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-th-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-3 bg-th-card/80 text-th-text-s">{t('auth.orContinueWith')}</span>
</div>
</div>
<a
href="/api/oauth/authorize"
className="btn-secondary w-full py-3 flex items-center justify-center gap-2"
>
<LogIn size={18} />
{t('auth.registerWithOAuth').replace('{provider}', oauthDisplayName || 'SSO')}
</a>
</>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.hasAccount')}{' '}
<Link to="/login" className="text-th-accent hover:underline font-medium">