All checks were successful
Build & Push Docker Image / build (push) Successful in 6m35s
273 lines
9.8 KiB
JavaScript
273 lines
9.8 KiB
JavaScript
/**
|
||
* 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, getBaseUrl } from '../middleware/auth.js';
|
||
import { log } from '../config/logger.js';
|
||
import redis from '../config/redis.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 = getBaseUrl(req);
|
||
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 = getBaseUrl(req);
|
||
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);
|
||
|
||
// Store id_token in Redis for RP-Initiated Logout (Keycloak SLO)
|
||
if (tokenResponse.id_token) {
|
||
try {
|
||
await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token);
|
||
} catch (redisErr) {
|
||
log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`);
|
||
}
|
||
}
|
||
|
||
// Redirect to frontend callback page with token.
|
||
// Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers).
|
||
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 = getBaseUrl(req);
|
||
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`);
|
||
}
|
||
});
|
||
|
||
export default router;
|