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.
276 lines
9.4 KiB
JavaScript
276 lines
9.4 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|