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

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();
}