feat: implement OAuth 2.0 / OpenID Connect support
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m12s
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:
@@ -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;
|
||||
|
||||
@@ -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
261
server/routes/oauth.js
Normal 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;
|
||||
Reference in New Issue
Block a user