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:
@@ -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
275
server/config/oauth.js
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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