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

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