diff --git a/backend/index.js b/backend/index.js index 0028566727..bd3ec238b5 100644 --- a/backend/index.js +++ b/backend/index.js @@ -3,7 +3,9 @@ import app from "./app.js"; import internalCertificate from "./internal/certificate.js"; import internalIpRanges from "./internal/ip_ranges.js"; +import { logFileConfigAudit } from "./internal/oidc.js"; import { global as logger } from "./logger.js"; +import { loadFileConfig } from "./lib/oidc-file-config.js"; import { migrateUp } from "./migrate.js"; import { getCompiledSchema } from "./schema/index.js"; import setup from "./setup.js"; @@ -14,6 +16,12 @@ async function appStart() { return migrateUp() .then(setup) .then(getCompiledSchema) + .then(() => { + // Eagerly load file-based OIDC config so errors surface at startup, + // not on the first authentication attempt. + loadFileConfig(); + return logFileConfigAudit(); + }) .then(() => { if (!IP_RANGES_FETCH_ENABLED) { logger.info("IP Ranges fetch is disabled by environment variable"); diff --git a/backend/internal/2fa.js b/backend/internal/2fa.js index 43307e02c3..6a49d953e3 100644 --- a/backend/internal/2fa.js +++ b/backend/internal/2fa.js @@ -6,6 +6,25 @@ import authModel from "../models/auth.js"; import internalUser from "./user.js"; const APP_NAME = "Nginx Proxy Manager"; + +/** + * Guard that throws if the user has no password auth record (i.e. OIDC-only). + * 2FA is tied to password auth and should not be available for externally + * authenticated accounts — their IdP manages security. + */ +const requirePasswordAuth = async (userId) => { + const auth = await authModel + .query() + .where("user_id", userId) + .andWhere("type", "password") + .first(); + if (!auth) { + throw new errs.ValidationError( + "Two-factor authentication is not available for externally authenticated accounts", + ); + } + return auth; +}; const BACKUP_CODE_COUNT = 8; /** @@ -33,8 +52,13 @@ const internal2fa = { * @returns {Promise} */ isEnabled: async (userId) => { - const auth = await internal2fa.getUserPasswordAuth(userId); - return auth?.meta?.totp_enabled === true; + try { + const auth = await internal2fa.getUserPasswordAuth(userId); + return auth?.meta?.totp_enabled === true; + } catch { + // No password auth record exists (e.g. OIDC-only user) — 2FA not possible + return false; + } }, /** @@ -46,7 +70,7 @@ const internal2fa = { getStatus: async (access, userId) => { await access.can("users:password", userId); await internalUser.get(access, { id: userId }); - const auth = await internal2fa.getUserPasswordAuth(userId); + const auth = await requirePasswordAuth(userId); const enabled = auth?.meta?.totp_enabled === true; let backup_codes_remaining = 0; @@ -77,7 +101,7 @@ const internal2fa = { label: user.email, secret: secret, }); - const auth = await internal2fa.getUserPasswordAuth(userId); + const auth = await requirePasswordAuth(userId); // ensure user isn't already setup for 2fa const enabled = auth?.meta?.totp_enabled === true; @@ -109,7 +133,7 @@ const internal2fa = { enable: async (access, userId, code) => { await access.can("users:password", userId); await internalUser.get(access, { id: userId }); - const auth = await internal2fa.getUserPasswordAuth(userId); + const auth = await requirePasswordAuth(userId); const secret = auth?.meta?.totp_pending_secret || false; if (!secret) { @@ -153,7 +177,7 @@ const internal2fa = { disable: async (access, userId, code) => { await access.can("users:password", userId); await internalUser.get(access, { id: userId }); - const auth = await internal2fa.getUserPasswordAuth(userId); + const auth = await requirePasswordAuth(userId); const enabled = auth?.meta?.totp_enabled === true; if (!enabled) { @@ -194,7 +218,13 @@ const internal2fa = { * @returns {Promise} */ verifyForLogin: async (userId, token) => { - const auth = await internal2fa.getUserPasswordAuth(userId); + let auth; + try { + auth = await internal2fa.getUserPasswordAuth(userId); + } catch { + // No password auth record (OIDC-only user) — 2FA not applicable + return false; + } const secret = auth?.meta?.totp_secret || false; if (!secret) { @@ -254,7 +284,7 @@ const internal2fa = { regenerateBackupCodes: async (access, userId, token) => { await access.can("users:password", userId); await internalUser.get(access, { id: userId }); - const auth = await internal2fa.getUserPasswordAuth(userId); + const auth = await requirePasswordAuth(userId); const enabled = auth?.meta?.totp_enabled === true; const secret = auth?.meta?.totp_secret || false; diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..7150e9aa93 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -83,10 +83,7 @@ const internalNginx = { meta: combined_meta, }) .then(() => { - internalNginx.renameConfigAsError(host_type, host); - }) - .then(() => { - return internalNginx.deleteConfig(host_type, host, true); + return internalNginx.renameConfigAsError(host_type, host); }); }); }) @@ -375,8 +372,10 @@ const internalNginx = { const config_file_err = `${config_file}.err`; return new Promise((resolve /*, reject*/) => { - fs.unlink(config_file, () => { - // ignore result, continue + // Remove any pre-existing .err file first, then rename the current + // config so it is preserved for debugging instead of being lost. + fs.unlink(config_file_err, () => { + // ignore result — the .err file may not exist fs.rename(config_file, config_file_err, () => { // also ignore result, as this is a debugging informative file anyway resolve(); @@ -419,7 +418,15 @@ const internalNginx = { * @param {string} config * @returns {boolean} */ - advancedConfigHasDefaultLocation: (cfg) => !!cfg.match(/^(?:.*;)?\s*?location\s*?\/\s*?{/im), + advancedConfigHasDefaultLocation: (cfg) => { + // Strip comment lines so they don't interfere with detection + const stripped = cfg.replace(/^\s*#.*$/gm, ""); + // Match location blocks that resolve to "/" (root path), including + // nginx modifiers like "=", "~", "~*", "^~" and optional whitespace. + // The (?:^|;) prefix with \s* handles both start-of-line and the rare + // case where a directive and a location block share a line. + return !!stripped.match(/(?:^|;)\s*location\s+(?:[=~^~*]{1,2}\s+)?\/\s*\{/im); + }, /** * @returns {boolean} diff --git a/backend/internal/oidc.js b/backend/internal/oidc.js new file mode 100644 index 0000000000..264989c879 --- /dev/null +++ b/backend/internal/oidc.js @@ -0,0 +1,1167 @@ +import * as oidcClient from "openid-client"; +import errs from "../lib/error.js"; +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; +import { debug, oidc as logger } from "../logger.js"; +import authModel from "../models/auth.js"; +import settingModel from "../models/setting.js"; +import TokenModel from "../models/token.js"; +import userModel from "../models/user.js"; +import userPermissionModel from "../models/user_permission.js"; +import internalAuditLog from "./audit-log.js"; +import internalToken from "./token.js"; +import twoFactor from "./2fa.js"; +import Access from "../lib/access.js"; +import gravatar from "gravatar"; +import { getFileProviders } from "../lib/oidc-file-config.js"; + +const SETTING_ID = "oidc-config"; + +// Discovery cache: maps discovery_url -> { config, expiresAt } +const discoveryCache = new Map(); +const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Validate that a URL uses HTTPS (SSRF mitigation) + * @param {string} url + * @throws {ConfigurationError} if not HTTPS + */ +function enforceHttps(url) { + if (!url || !url.startsWith("https://")) { + throw new errs.ConfigurationError("OIDC discovery URL must use HTTPS"); + } +} + +/** + * Get the discovered OIDC configuration for a provider, with caching. + * @param {Object} providerConfig + * @returns {Promise} + */ +async function discoverProvider(providerConfig) { + const cacheKey = providerConfig.discovery_url; + const cached = discoveryCache.get(cacheKey); + + if (cached && cached.expiresAt > Date.now()) { + debug(logger, `Using cached OIDC discovery for ${cacheKey}`); + return cached.config; + } + + debug(logger, `Discovering OIDC provider at ${cacheKey}`); + enforceHttps(cacheKey); + + let config; + try { + config = await oidcClient.discovery( + new URL(providerConfig.discovery_url), + providerConfig.client_id, + { client_secret: providerConfig.client_secret }, + oidcClient.ClientSecretBasic(), + ); + } catch (err) { + throw new errs.ConfigurationError(`OIDC provider is not reachable or returned invalid discovery document: ${err.message}`); + } + + discoveryCache.set(cacheKey, { config, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS }); + return config; +} + +/** + * Get the plaintext client secret for a provider, handling both DB-sourced + * (AES-256-GCM encrypted) and file-sourced (already plaintext) providers. + * + * @param {Object} provider - Provider object with _source and client_secret fields + * @returns {string} plaintext client secret + */ +function getPlaintextSecret(provider) { + if (!provider.client_secret) { + return ""; + } + if (provider._source === "file") { + // File-sourced secrets are already plaintext (env-var-expanded at load time) + return provider.client_secret; + } + return decryptSecret(provider.client_secret); +} + +const internalOidc = { + + /** + * Get the full OIDC configuration, merging DB-stored and file-sourced providers. + * File providers take precedence on ID conflict. + * DB providers have _source: "db"; file providers have _source: "file". + * + * @returns {Promise<{providers: Array}>} + */ + getRawConfig: async () => { + const row = await settingModel + .query() + .where("id", SETTING_ID) + .first(); + + const dbConfig = row?.meta || { providers: [] }; + const dbProviders = (dbConfig.providers || []).map((p) => ({ ...p, _source: "db" })); + + // File-sourced providers (already have _source: "file") + const fileProviders = getFileProviders(); + const fileIds = new Set(fileProviders.map((p) => p.id)); + + // Merge: file wins on ID conflict + const mergedDb = dbProviders.filter((p) => { + if (fileIds.has(p.id)) { + logger.warn(`OIDC provider "${p.id}" exists in both DB and file config — file config takes precedence`); + return false; + } + return true; + }); + + return { providers: [...fileProviders, ...mergedDb] }; + }, + + /** + * Get the list of enabled providers for the login page (public-safe, no secrets). + * + * @returns {Promise>} + */ + getEnabledProviders: async () => { + const config = await internalOidc.getRawConfig(); + return (config.providers || []) + .filter((p) => p.enabled) + .map((p) => ({ id: p.id, name: p.name, source: p._source || "db" })); + }, + + /** + * Get OIDC configuration (admin-only). Redacts client secrets. + * + * @param {Access} access + * @returns {Promise<{providers: Array}>} + */ + getConfig: async (access) => { + await access.can("settings:get"); + + const config = await internalOidc.getRawConfig(); + const providers = (config.providers || []).map((p) => ({ + ...p, + // Expose source to the frontend (strip internal underscore-prefixed field) + source: p._source || "db", + _source: undefined, + // Redact client secret - replace with placeholder if set + client_secret: p.client_secret ? "••••••••" : "", + })); + + return { providers }; + }, + + /** + * Save OIDC configuration (admin-only). + * Encrypts client secrets, validates discovery URLs. + * + * @param {Access} access + * @param {Object} data + * @returns {Promise<{providers: Array}>} + */ + saveConfig: async (access, data) => { + await access.can("settings:update"); + + const existingConfig = await internalOidc.getRawConfig(); + const existingProviders = existingConfig.providers || []; + + // Identify file-sourced provider IDs — these cannot be modified via the API + const fileProviderIds = new Set(getFileProviders().map((p) => p.id)); + + const providers = []; + for (const provider of (data.providers || [])) { + // Skip file-sourced providers silently — they are read-only + if (fileProviderIds.has(provider.id)) { + logger.warn(`OIDC saveConfig: ignoring attempt to modify file-sourced provider "${provider.id}" via API`); + continue; + } + + // Enforce HTTPS on all discovery URLs + enforceHttps(provider.discovery_url); + + // Determine the client secret to store: + // - If the incoming value is the redaction placeholder or empty, keep existing + // - Otherwise encrypt the new value + let clientSecret = provider.client_secret; + if (clientSecret === "••••••••" || clientSecret === "") { + // Keep existing encrypted secret (DB providers only at this point) + const existing = existingProviders.find((p) => p.id === provider.id && p._source !== "file"); + clientSecret = existing ? existing.client_secret : ""; + } else { + // Encrypt the new plaintext secret + clientSecret = encryptSecret(clientSecret); + } + + // Validate by attempting discovery (uses the decrypted secret) + const decryptedSecret = clientSecret ? decryptSecret(clientSecret) : ""; + try { + enforceHttps(provider.discovery_url); + const testConfig = { + discovery_url: provider.discovery_url, + client_id: provider.client_id, + client_secret: decryptedSecret, + }; + // Invalidate cache so we get a fresh discovery + discoveryCache.delete(provider.discovery_url); + await discoverProvider(testConfig); + } catch (err) { + throw new errs.ConfigurationError(`OIDC provider "${provider.name}" validation failed: ${err.message}`); + } + + providers.push({ + id: provider.id, + name: provider.name, + discovery_url: provider.discovery_url, + client_id: provider.client_id, + client_secret: clientSecret, + scopes: provider.scopes || "openid email profile", + enabled: provider.enabled, + use_par: provider.use_par || false, + auto_provision: provider.auto_provision || false, + auto_provision_role: "user", // Always "user" — never "admin" + claim_mapping: provider.claim_mapping || { + email: "email", + name: "name", + nickname: "preferred_username", + avatar: "picture", + }, + }); + } + + const newMeta = { providers }; + + // Check if setting row exists + const existing = await settingModel.query().where("id", SETTING_ID).first(); + if (existing) { + await settingModel.query().where("id", SETTING_ID).patch({ meta: newMeta }); + } else { + await settingModel.query().insert({ + id: SETTING_ID, + name: "OIDC Configuration", + description: "OpenID Connect provider configuration", + value: "enabled", + meta: newMeta, + }); + } + + // Determine per-provider changes for audit logging (DB providers only) + const existingDbProviders = existingProviders.filter((p) => p._source !== "file"); + const existingIds = new Set(existingDbProviders.map((p) => p.id)); + const newIds = new Set(providers.map((p) => p.id)); + + const added = providers.filter((p) => !existingIds.has(p.id)); + const removed = existingDbProviders.filter((p) => !newIds.has(p.id)); + const updated = providers.filter((p) => { + if (!existingIds.has(p.id)) return false; + const prev = existingDbProviders.find((ep) => ep.id === p.id); + if (!prev) return false; + // Compare non-secret fields + return ( + prev.name !== p.name || + prev.discovery_url !== p.discovery_url || + prev.client_id !== p.client_id || + prev.client_secret !== p.client_secret || + prev.scopes !== p.scopes || + prev.enabled !== p.enabled || + prev.use_par !== p.use_par || + prev.auto_provision !== p.auto_provision || + JSON.stringify(prev.claim_mapping) !== JSON.stringify(p.claim_mapping) + ); + }); + + const providerSummary = (p) => ({ + id: p.id, + name: p.name, + enabled: p.enabled, + discovery_url: p.discovery_url, + client_id: p.client_id, + scopes: p.scopes, + use_par: p.use_par, + auto_provision: p.auto_provision, + claim_mapping: p.claim_mapping, + }); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "setting", + object_id: 0, + meta: { + name: "OIDC Configuration", + provider_count: providers.length, + added: added.map(providerSummary), + updated: updated.map(providerSummary), + removed: removed.map((p) => ({ id: p.id, name: p.name })), + }, + }); + + return internalOidc.getConfig(access); + }, + + /** + * Build an OIDC authorization URL for the given provider. + * Generates PKCE code_verifier and stores it in a signed state JWT. + * + * @param {string} providerId + * @param {string} callbackUrl - Full callback URL (e.g. https://app.example.com/api/oidc/callback) + * @returns {Promise<{authorizeUrl: string, stateToken: string}>} + */ + buildAuthorizationUrl: async (providerId, callbackUrl) => { + const config = await internalOidc.getRawConfig(); + const providerConfig = (config.providers || []).find((p) => p.id === providerId && p.enabled); + + if (!providerConfig) { + throw new errs.ItemNotFoundError(`OIDC provider "${providerId}" not found or not enabled`); + } + + const decryptedSecret = getPlaintextSecret(providerConfig); + const providerConfigWithSecret = { ...providerConfig, client_secret: decryptedSecret }; + + const oidcConfig = await discoverProvider(providerConfigWithSecret); + + // Generate PKCE values (RFC 7636) + const codeVerifier = oidcClient.randomPKCECodeVerifier(); + const codeChallenge = await oidcClient.calculatePKCECodeChallenge(codeVerifier); + const nonce = oidcClient.randomNonce(); + + // Create a short-lived state JWT containing the PKCE verifier and nonce + const Token = TokenModel(); + const stateJwt = await Token.create({ + iss: "api", + attrs: { + provider_id: providerId, + code_verifier: codeVerifier, + nonce: nonce, + redirect_uri: callbackUrl, + }, + scope: ["oidc-state"], + expiresIn: "5m", + }); + + // Build the authorization URL with PKCE + const scopes = (providerConfig.scopes || "openid email profile").split(" ").filter(Boolean); + + const authParams = { + redirect_uri: callbackUrl, + scope: scopes.join(" "), + state: stateJwt.token, + nonce: nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }; + + let authorizeUrl; + if (providerConfig.use_par) { + // Pushed Authorization Request (RFC 9126) — sends params server-side + debug(logger, `Using PAR for provider ${providerId}`); + try { + authorizeUrl = await oidcClient.buildAuthorizationUrlWithPAR(oidcConfig, authParams); + } catch (err) { + logger.error(`PAR request failed for provider ${providerId}: ${err.message}`); + if (err.cause) { + logger.error(` cause: ${JSON.stringify(err.cause)}`); + } + if (err.code) { + logger.error(` error_code: ${err.code}`); + } + + // Build a user-friendly message from the provider's OAuth error response + const detail = err.error_description || err.error || err.message; + throw new errs.ConfigurationError(`OIDC provider "${providerId}" PAR request failed: ${detail}`); + } + } else { + authorizeUrl = oidcClient.buildAuthorizationUrl(oidcConfig, authParams); + } + + return { authorizeUrl: authorizeUrl.href }; + }, + + /** + * Build an OIDC authorization URL for account linking. + * Returns the PKCE code_verifier, nonce, and a signed state JWT so the + * frontend can complete the link POST after the popup callback. + * + * @param {Access} access - must be authenticated + * @param {string} providerId + * @param {string} callbackUrl + * @returns {Promise<{authorizeUrl: string, codeVerifier: string, nonce: string, state: string}>} + */ + buildLinkAuthorizationUrl: async (access, providerId, callbackUrl) => { + const userId = access.token.getUserId(); + if (!userId) { + throw new errs.AuthError("Not authenticated"); + } + + const config = await internalOidc.getRawConfig(); + const providerConfig = (config.providers || []).find((p) => p.id === providerId && p.enabled); + if (!providerConfig) { + throw new errs.ItemNotFoundError(`OIDC provider "${providerId}" not found or not enabled`); + } + + const decryptedSecret = getPlaintextSecret(providerConfig); + const providerConfigWithSecret = { ...providerConfig, client_secret: decryptedSecret }; + const oidcConfig = await discoverProvider(providerConfigWithSecret); + + const codeVerifier = oidcClient.randomPKCECodeVerifier(); + const codeChallenge = await oidcClient.calculatePKCECodeChallenge(codeVerifier); + const nonce = oidcClient.randomNonce(); + + // Create a short-lived state JWT for CSRF protection (mirrors login flow) + const Token = TokenModel(); + const stateJwt = await Token.create({ + iss: "api", + attrs: { + provider_id: providerId, + redirect_uri: callbackUrl, + }, + scope: ["oidc-link-state"], + expiresIn: "5m", + }); + + const scopes = (providerConfig.scopes || "openid email profile").split(" ").filter(Boolean); + + const authParams = { + redirect_uri: callbackUrl, + scope: scopes.join(" "), + state: stateJwt.token, + nonce: nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }; + + let authorizeUrl; + if (providerConfig.use_par) { + authorizeUrl = await oidcClient.buildAuthorizationUrlWithPAR(oidcConfig, authParams); + } else { + authorizeUrl = oidcClient.buildAuthorizationUrl(oidcConfig, authParams); + } + + return { + authorizeUrl: authorizeUrl.href, + codeVerifier, + nonce, + state: stateJwt.token, + }; + }, + + /** + * Handle the OIDC callback — exchange code for tokens, resolve user, issue NPM JWT. + * + * The full redirect URL from the browser (with all query params including code, + * state, iss, session_state etc.) is passed through to openid-client so it can + * perform proper RFC 9207 issuer validation. + * + * @param {string} stateToken - State JWT from our authorize request (extracted from query) + * @param {string} currentUrl - The full URL the browser was redirected to + * @returns {Promise<{token: string, expires: string} | {requires_2fa: true, challenge_token: string}>} + */ + handleCallback: async (stateToken, currentUrl) => { + // Validate state JWT + const Token = TokenModel(); + let stateData; + try { + stateData = await Token.load(stateToken); + } catch { + throw new errs.AuthError("OIDC login session expired. Please try again."); + } + + if (!stateData.scope || stateData.scope[0] !== "oidc-state") { + throw new errs.AuthError("OIDC login session expired. Please try again."); + } + + const { provider_id: providerId, code_verifier: codeVerifier, nonce, redirect_uri: callbackUrl } = stateData.attrs || {}; + + if (!providerId || !codeVerifier || !nonce || !callbackUrl) { + throw new errs.AuthError("OIDC login session expired. Please try again."); + } + + // Load provider config + const config = await internalOidc.getRawConfig(); + const providerConfig = (config.providers || []).find((p) => p.id === providerId && p.enabled); + + if (!providerConfig) { + throw new errs.AuthError("OIDC provider is no longer configured or enabled."); + } + + const decryptedSecret = getPlaintextSecret(providerConfig); + const providerConfigWithSecret = { ...providerConfig, client_secret: decryptedSecret }; + const oidcConfig = await discoverProvider(providerConfigWithSecret); + + // Build the URL for openid-client: use the stored redirect_uri as the base + // but replace query params with the actual ones from the browser redirect. + // This ensures the redirect_uri matches what was sent to the authorize endpoint + // while preserving all provider-added params (iss, session_state, etc.) + const incomingUrl = new URL(currentUrl); + const tokenExchangeUrl = new URL(callbackUrl); + tokenExchangeUrl.search = incomingUrl.search; + + // Exchange the authorization code for tokens (with PKCE verification) + let tokens; + try { + tokens = await oidcClient.authorizationCodeGrant(oidcConfig, tokenExchangeUrl, { + pkceCodeVerifier: codeVerifier, + expectedNonce: nonce, + expectedState: stateToken, + idTokenExpected: true, + }); + } catch (err) { + logger.error(`OIDC token exchange failed: ${err.message}`); + if (err.cause) { + logger.error(` cause: ${err.cause.message || err.cause}`); + } + if (err.response) { + logger.error(` response status: ${err.response.status}`); + try { + const body = await err.response.text(); + logger.error(` response body: ${body}`); + } catch { /* ignore */ } + } + if (err.code) { + logger.error(` code: ${err.code}`); + } + throw new errs.AuthError("OIDC authentication failed"); + } + + // Extract claims using claim mapping + const claimMapping = providerConfig.claim_mapping || {}; + const emailClaim = claimMapping.email || "email"; + const nameClaim = claimMapping.name || "name"; + const nicknameClaim = claimMapping.nickname || "preferred_username"; + const avatarClaim = claimMapping.avatar || "picture"; + + let claims = tokens.claims(); + debug(logger, `ID token claims: ${JSON.stringify(Object.keys(claims))}`); + + // Many providers only include email/profile claims in the userinfo + // endpoint, not in the ID token itself. If the email claim is missing, + // fetch userinfo and merge the additional claims. + if (!claims[emailClaim] && tokens.access_token) { + debug(logger, "Email claim missing from ID token, fetching userinfo endpoint"); + try { + const userInfo = await oidcClient.fetchUserInfo(oidcConfig, tokens.access_token, claims.sub); + debug(logger, `Userinfo claims: ${JSON.stringify(Object.keys(userInfo))}`); + // Merge userinfo into claims (ID token claims take precedence) + claims = { ...userInfo, ...claims }; + } catch (err) { + logger.warn(`Failed to fetch userinfo endpoint: ${err.message}`); + } + } + + const sub = claims.sub; + const email = claims[emailClaim]; + const name = claims[nameClaim] || email; + const nickname = claims[nicknameClaim] || email?.split("@")[0] || "user"; + const avatar = claims[avatarClaim] || gravatar.url(email || "unknown@example.com", { default: "mm" }); + + if (!sub) { + throw new errs.AuthError("OIDC provider did not return a subject identifier"); + } + + if (!email) { + throw new errs.AuthError("OIDC provider did not return an email address. Ensure the 'email' scope is configured."); + } + + // Resolve or provision the local user + const user = await internalOidc.resolveUser( + { sub, email, name, nickname, avatar, provider_id: providerId }, + providerConfig, + ); + + // Check 2FA (don't bypass it for OIDC users) + const has2FA = await twoFactor.isEnabled(user.id); + if (has2FA) { + const challengeToken = await Token.create({ + iss: "api", + attrs: { id: user.id }, + scope: ["2fa-challenge"], + expiresIn: "5m", + }); + + return { + requires_2fa: true, + challenge_token: challengeToken.token, + }; + } + + // Update last login in auth meta + const oidcAuthRecords = await authModel + .query() + .where("user_id", user.id) + .where("type", "oidc"); + + const oidcAuth = oidcAuthRecords.find( + (r) => r.meta && r.meta.provider_id === providerId, + ); + + if (oidcAuth) { + await authModel + .query() + .where("id", oidcAuth.id) + .patch({ meta: { ...oidcAuth.meta, provider_id: providerId, issuer: claims.iss, last_login: new Date().toISOString() } }); + } + + const result = await internalToken.getTokenFromUser(user); + // Annotate with oidc provider info for logout support + result.oidc_provider = providerId; + return result; + }, + + /** + * Resolve a local user from OIDC claims. + * SECURITY: No unauthenticated email-based account linking. + * Only matches by (type='oidc', sub, provider_id) or auto-provisions a NEW user. + * + * @param {Object} claims - { sub, email, name, nickname, avatar, provider_id } + * @param {Object} providerConfig + * @returns {Promise} local user + */ + resolveUser: async (claims, providerConfig) => { + // Look for existing OIDC auth record matching sub + provider + // Query all OIDC auth records for this sub, then match provider_id. + // A sub claim is only unique per issuer, so we must match provider_id too. + const authRecords = await authModel + .query() + .where("type", "oidc") + .where("secret", claims.sub); + + const authRecord = authRecords.find( + (r) => r.meta && r.meta.provider_id === claims.provider_id, + ); + + if (authRecord) { + const user = await userModel + .query() + .where("id", authRecord.user_id) + .first(); + + if (user && user.is_disabled) { + throw new errs.AuthError("Account is disabled"); + } + + if (user && !user.is_deleted) { + return user; + } + + // User was deleted — remove the stale OIDC auth record so the + // identity can be re-provisioned as a new account below. + logger.info(`Removing stale OIDC auth record for deleted user_id=${authRecord.user_id}`); + await authModel.query().deleteById(authRecord.id); + } + + // No existing OIDC link found — check auto-provisioning + if (!providerConfig.auto_provision) { + throw new errs.AuthError("No account exists for this OIDC identity. Contact your administrator."); + } + + // Auto-provision a NEW user (never link to existing accounts based on email) + logger.info(`Auto-provisioning new user for OIDC subject: ${claims.sub} from provider: ${claims.provider_id}`); + + const newUser = await userModel.query().insertAndFetch({ + email: claims.email, + name: claims.name || claims.email, + nickname: claims.nickname || claims.email.split("@")[0], + avatar: claims.avatar || gravatar.url(claims.email, { default: "mm" }), + roles: [], // Standard user — NEVER "admin" (security guardrail) + is_deleted: 0, + is_disabled: 0, + }); + + await authModel.query().insert({ + user_id: newUser.id, + type: "oidc", + secret: claims.sub, + meta: { + provider_id: claims.provider_id, + issuer: "", + last_login: new Date().toISOString(), + }, + }); + + await userPermissionModel.query().insert({ + user_id: newUser.id, + visibility: "user", + proxy_hosts: "manage", + redirection_hosts: "manage", + dead_hosts: "manage", + streams: "manage", + access_lists: "manage", + certificates: "manage", + }); + + // Audit log with internal access + const internalAccess = new Access(null); + await internalAccess.load(true); + + await internalAuditLog.add(internalAccess, { + action: "created", + object_type: "user", + object_id: newUser.id, + user_id: newUser.id, + meta: { + ...newUser, + oidc_provider_id: claims.provider_id, + }, + }); + + return newUser; + }, + + /** + * Link an OIDC identity to an already-authenticated user. + * This is the ONLY way to link OIDC to an existing account. + * Requires: valid NPM JWT (authenticated user). + * + * @param {Access} access - authenticated user's access context + * @param {string} providerId + * @param {string} codeVerifier - PKCE code verifier + * @param {string} nonce - Nonce used in the authorization request + * @param {string} callbackUrl - redirect_uri used in the authorization request + * @param {string} state - State JWT from the authorize-link request (CSRF protection) + * @param {string} queryString - Full IdP callback query string for RFC 9207 validation + * @returns {Promise} + */ + linkOidcIdentity: async (access, providerId, codeVerifier, nonce, callbackUrl, state, queryString) => { + const userId = access.token.getUserId(); + if (!userId) { + throw new errs.AuthError("Not authenticated"); + } + + if (!state) { + throw new errs.AuthError("Missing state parameter for OIDC link request"); + } + + // Validate state JWT (CSRF protection for link flow) + const Token = TokenModel(); + let stateData; + try { + stateData = await Token.load(state); + } catch { + throw new errs.AuthError("OIDC link session expired. Please try again."); + } + if (!stateData.scope || stateData.scope[0] !== "oidc-link-state") { + throw new errs.AuthError("OIDC link session expired. Please try again."); + } + const attrs = stateData.attrs || {}; + if (attrs.provider_id !== providerId) { + throw new errs.AuthError("OIDC link state does not match provider"); + } + if (attrs.redirect_uri !== callbackUrl) { + throw new errs.AuthError("OIDC link state does not match callback URL"); + } + + const config = await internalOidc.getRawConfig(); + const providerConfig = (config.providers || []).find((p) => p.id === providerId && p.enabled); + + if (!providerConfig) { + throw new errs.ItemNotFoundError(`OIDC provider "${providerId}" not found`); + } + + const decryptedSecret = getPlaintextSecret(providerConfig); + const providerConfigWithSecret = { ...providerConfig, client_secret: decryptedSecret }; + const oidcConfig = await discoverProvider(providerConfigWithSecret); + + // Exchange code for tokens using PKCE + nonce validation. + // Reconstruct the full callback URL the same way the login flow does: + // use the stored redirect_uri as the base and attach the original query + // params from the IdP redirect (code, state, iss, session_state, etc.) + // so openid-client can perform proper RFC 9207 issuer validation. + const tokenExchangeUrl = new URL(callbackUrl); + if (queryString) { + tokenExchangeUrl.search = queryString; + } + let tokens; + try { + const grantOptions = { + pkceCodeVerifier: codeVerifier, + expectedNonce: nonce, + expectedState: state, + idTokenExpected: true, + }; + tokens = await oidcClient.authorizationCodeGrant(oidcConfig, tokenExchangeUrl, grantOptions); + } catch (err) { + logger.error(`OIDC link code exchange failed: ${err.message || err}`); + throw new errs.AuthError("OIDC code exchange failed during account linking"); + } + + const claims = tokens.claims(); + const sub = claims.sub; + + if (!sub) { + throw new errs.AuthError("OIDC provider did not return a subject identifier"); + } + + // Check if this OIDC identity is already linked to another user + // Query all records for this sub and match provider_id in application code + const existingAuthRecords = await authModel + .query() + .where("type", "oidc") + .where("secret", sub); + + const existingAuth = existingAuthRecords.find( + (r) => r.meta && r.meta.provider_id === providerId, + ); + + if (existingAuth) { + if (existingAuth.user_id === userId) { + throw new errs.ValidationError("This OIDC identity is already linked to your account"); + } + throw new errs.ValidationError("This OIDC identity is already linked to another account"); + } + + // Create the auth record linking current user to OIDC identity + await authModel.query().insert({ + user_id: userId, + type: "oidc", + secret: sub, + meta: { + provider_id: providerId, + issuer: claims.iss || "", + last_login: new Date().toISOString(), + }, + }); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "user", + object_id: userId, + user_id: userId, + meta: { + name: (await userModel.query().findById(userId))?.name || `User #${userId}`, + provider_id: providerId, + provider_name: providerConfig.name || providerId, + oidc_action: "link", + }, + }); + }, + + /** + * Get the OIDC identities linked to a user. + * @param {number} userId + * @returns {Promise>} + */ + getUserIdentities: async (userId) => { + const authRecords = await authModel + .query() + .where("user_id", userId) + .where("type", "oidc"); + + const config = await internalOidc.getRawConfig(); + const providers = config.providers || []; + + return authRecords.map((record) => { + const providerId = record.meta?.provider_id || "unknown"; + const providerConfig = providers.find((p) => p.id === providerId); + return { + provider_id: providerId, + provider_name: providerConfig?.name || providerId, + linked_on: record.created_on, + }; + }); + }, + + /** + * Unlink an OIDC identity from a user. + * Safety: refuses if this is the user's only auth method. + * + * @param {Access} access + * @param {string} providerId + * @returns {Promise} + */ + unlinkOidcIdentity: async (access, providerId) => { + const userId = access.token.getUserId(); + if (!userId) { + throw new errs.AuthError("Not authenticated"); + } + + // Find the OIDC auth record for this provider + const oidcAuthRecords = await authModel + .query() + .where("user_id", userId) + .where("type", "oidc"); + + const targetRecord = oidcAuthRecords.find( + (r) => r.meta && r.meta.provider_id === providerId, + ); + + if (!targetRecord) { + throw new errs.ItemNotFoundError( + `No OIDC identity linked for provider "${providerId}"`, + ); + } + + // Safety check: user must have at least one other auth method. + // NOTE: This is a TOCTOU check — two concurrent unlink requests for + // different providers could both pass before either delete completes, + // leaving the user with zero auth methods. This is low-severity (self-DoS + // only, requires precise timing with exactly 2 remaining methods) and the + // codebase does not currently use database transactions. If this becomes a + // concern, wrap the count check + delete in a serializable transaction. + const allAuthRecords = await authModel + .query() + .where("user_id", userId); + + if (allAuthRecords.length <= 1) { + throw new errs.ValidationError( + "Cannot unlink your only authentication method. You would be locked out.", + ); + } + + // Delete the auth record + await authModel.query().deleteById(targetRecord.id); + + // Fetch user name and provider name for the audit log + const user = await userModel.query().findById(userId); + const config = await internalOidc.getRawConfig(); + const provider = (config.providers || []).find((p) => p.id === providerId); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "user", + object_id: userId, + user_id: userId, + meta: { + name: user?.name || `User #${userId}`, + provider_id: providerId, + provider_name: provider?.name || providerId, + oidc_action: "unlink", + }, + }); + }, + + /** + * Count users linked to a specific OIDC provider. + * Admin-only. + * + * @param {Access} access + * @param {string} providerId + * @returns {Promise<{total: number, oidc_only: number}>} + */ + getProviderUserCount: async (access, providerId) => { + await access.can("settings:update"); + + // Get all OIDC auth records, filter by provider_id in meta + const oidcAuthRecords = await authModel + .query() + .where("type", "oidc"); + + const providerRecords = oidcAuthRecords.filter( + (r) => r.meta && r.meta.provider_id === providerId, + ); + + const userIds = [...new Set(providerRecords.map((r) => r.user_id))]; + + if (userIds.length === 0) { + return { total: 0, oidc_only: 0 }; + } + + // For each user, check if they have ANY other auth method + const allAuthRecords = await authModel + .query() + .whereIn("user_id", userIds); + + let oidcOnlyCount = 0; + for (const userId of userIds) { + const userAuths = allAuthRecords.filter((r) => r.user_id === userId); + const nonProviderAuths = userAuths.filter( + (r) => !(r.type === "oidc" && r.meta?.provider_id === providerId), + ); + if (nonProviderAuths.length === 0) { + oidcOnlyCount++; + } + } + + return { total: userIds.length, oidc_only: oidcOnlyCount }; + }, + + /** + * Get the RP-initiated logout URL for a provider (if supported). + * + * @param {string} providerId + * @param {string} postLogoutRedirectUri + * @returns {Promise} logout URL or null if not supported + */ + getLogoutUrl: async (providerId, postLogoutRedirectUri) => { + const config = await internalOidc.getRawConfig(); + const providerConfig = (config.providers || []).find((p) => p.id === providerId && p.enabled); + + if (!providerConfig) { + return null; + } + + const decryptedSecret = getPlaintextSecret(providerConfig); + const providerConfigWithSecret = { ...providerConfig, client_secret: decryptedSecret }; + + let oidcConfig; + try { + oidcConfig = await discoverProvider(providerConfigWithSecret); + } catch { + return null; + } + + try { + const logoutUrl = oidcClient.buildEndSessionUrl(oidcConfig, { + post_logout_redirect_uri: postLogoutRedirectUri, + }); + return logoutUrl.href; + } catch { + // Provider doesn't support RP-initiated logout + return null; + } + }, + + /** + * Test OIDC provider connectivity by attempting discovery. + * Admin-only. Does not modify any stored configuration. + * + * @param {Access} access + * @param {Object} data + * @param {string} data.discovery_url + * @param {string} data.client_id + * @param {string} data.client_secret - plaintext or redaction placeholder + * @param {string} [data.provider_id] - if provided and secret is placeholder, look up existing + * @returns {Promise<{success: boolean}>} + */ + testConnection: async (access, data) => { + await access.can("settings:update"); + enforceHttps(data.discovery_url); + + // Resolve client secret: if it's the redaction placeholder, look up the existing secret + // (handles both DB-encrypted and file-sourced plaintext secrets) + let clientSecret = data.client_secret || ""; + if (clientSecret === "••••••••") { + if (data.provider_id) { + const existingConfig = await internalOidc.getRawConfig(); + const existing = (existingConfig.providers || []).find((p) => p.id === data.provider_id); + clientSecret = existing ? getPlaintextSecret(existing) : ""; + } else { + clientSecret = ""; + } + } + + // Bust the cache so we get a fresh discovery attempt + discoveryCache.delete(data.discovery_url); + + const oidcConfig = await discoverProvider({ + discovery_url: data.discovery_url, + client_id: data.client_id, + client_secret: clientSecret, + }); + + // Step 2: Validate credentials and scopes using PAR (Pushed Authorization Request). + // PAR sends all auth parameters (client credentials, scopes, redirect_uri, PKCE) + // to the provider's token endpoint server-side. The provider validates everything + // and returns a request_uri — or an error with specific details. + // This mirrors the real authorization_code + PKCE flow without requiring a browser. + const scopes = (data.scopes || "openid email profile").split(" ").filter(Boolean); + const metadata = oidcConfig.serverMetadata(); + const hasPar = !!metadata.pushed_authorization_request_endpoint; + + let credentials = "unsupported"; + let credentialsMessage = ""; + let scopesValid = true; + let unsupportedScopes = []; + + if (hasPar) { + // PAR validates credentials + scopes + redirect_uri in one round-trip + const codeVerifier = oidcClient.randomPKCECodeVerifier(); + const codeChallenge = await oidcClient.calculatePKCECodeChallenge(codeVerifier); + + try { + await oidcClient.buildAuthorizationUrlWithPAR(oidcConfig, { + redirect_uri: `${data.callback_base_url || "https://localhost"}/api/oidc/callback`, + scope: scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: "S256", + state: "npm-connection-test", + nonce: oidcClient.randomNonce(), + }); + // PAR succeeded — credentials and scopes are all valid + credentials = "valid"; + } catch (err) { + const oauthError = err.error || ""; + const oauthDesc = err.error_description || err.message || ""; + + if (oauthError === "invalid_client") { + credentials = "invalid"; + credentialsMessage = oauthDesc || "Invalid client credentials"; + } else if (oauthError === "invalid_scope") { + // Credentials are valid (provider authenticated us), but scopes are wrong + credentials = "valid"; + scopesValid = false; + credentialsMessage = oauthDesc || "One or more scopes are not allowed"; + } else if (oauthError === "invalid_redirect_uri") { + // Credentials and scopes are valid, redirect_uri isn't registered. + // This is expected during test — the real callback URL may not be configured yet. + credentials = "valid"; + } else { + credentials = "invalid"; + credentialsMessage = oauthDesc || oauthError || err.message; + } + debug(logger, `PAR test result for ${data.discovery_url}: error=${oauthError} desc=${oauthDesc}`); + } + } else { + // No PAR endpoint — can't validate credentials server-side. + credentials = "unsupported"; + credentialsMessage = "Provider does not support Pushed Authorization Requests. Credentials will be validated during user login."; + debug(logger, `No PAR endpoint for ${data.discovery_url}, skipping credential validation`); + } + + // Step 3: Check scopes against the discovery document's scopes_supported (advisory). + // This is informational even if PAR already validated scopes, and is the only + // scope check available when PAR is not supported. + if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) { + unsupportedScopes = scopes.filter((s) => !metadata.scopes_supported.includes(s)); + if (unsupportedScopes.length > 0) { + scopesValid = false; + } + } + + return { + success: true, + credentials, + credentials_message: credentialsMessage, + scopes_valid: scopesValid, + unsupported_scopes: unsupportedScopes, + }; + }, +}; + +// Audit log file-sourced providers once per process lifetime (no user context = system action) +let fileConfigAuditLogged = false; + +/** + * Log file-sourced OIDC providers to the audit log at startup. + * Safe to call multiple times — only logs once per process. + */ +async function logFileConfigAudit() { + if (fileConfigAuditLogged) { + return; + } + fileConfigAuditLogged = true; + + const fileProviders = getFileProviders(); + if (fileProviders.length === 0) { + return; + } + + try { + await internalAuditLog.add( + { token: { getUserId: () => 0 } }, + { + user_id: 0, + action: "loaded", + object_type: "setting", + object_id: 0, + meta: { + name: "OIDC File Configuration", + source: "file", + providers: fileProviders.map((p) => ({ id: p.id, name: p.name })), + }, + }, + ); + } catch (err) { + // Non-fatal — audit logging should not prevent startup + logger.warn(`OIDC file config: could not write audit log entry: ${err.message}`); + } +} + +export { logFileConfigAudit }; +export default internalOidc; diff --git a/backend/internal/user.js b/backend/internal/user.js index d13931d54a..35a3309b56 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -14,6 +14,20 @@ const omissions = () => { const DEFAULT_AVATAR = gravatar.url("admin@example.com", { default: "mm" }); +/** + * Check whether a user has a password-type auth record. + * @param {number} userId + * @returns {Promise} + */ +const hasPasswordAuth = async (userId) => { + const auth = await authModel + .query() + .where("user_id", userId) + .andWhere("type", "password") + .first(); + return !!auth; +}; + const internalUser = { /** * Create a user can happen unauthenticated only once and only when no active users exist. @@ -170,21 +184,26 @@ const internalUser = { return query.then(utils.omitRow(omissions())); }) - .then((row) => { - if (!row || !row.id) { - throw new errs.ItemNotFoundError(thisData.id); - } - // Custom omissions - if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { - return _.omit(row, thisData.omit); - } + .then(async (row) => { + if (!row || !row.id) { + throw new errs.ItemNotFoundError(thisData.id); + } - if (row.avatar === "") { - row.avatar = DEFAULT_AVATAR; - } + if (row.avatar === "") { + row.avatar = DEFAULT_AVATAR; + } - return row; - }); + // Enrich with auth type info so the frontend can hide + // password-only features (2FA, change password) for OIDC-only users + row.has_password_auth = await hasPasswordAuth(row.id); + + // Custom omissions + if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { + return _.omit(row, thisData.omit); + } + + return row; + }); }, /** @@ -318,7 +337,22 @@ const internalUser = { } const res = await query; - return utils.omitRows(omissions())(res); + const rows = utils.omitRows(omissions())(res); + + // Enrich each user with auth type info + const userIds = rows.map((r) => r.id); + const passwordAuths = await authModel + .query() + .where("type", "password") + .whereIn("user_id", userIds) + .select("user_id"); + const passwordAuthSet = new Set(passwordAuths.map((a) => a.user_id)); + + for (const row of rows) { + row.has_password_auth = passwordAuthSet.has(row.id); + } + + return rows; }, /** diff --git a/backend/lib/crypto.js b/backend/lib/crypto.js new file mode 100644 index 0000000000..d4fb58bcb5 --- /dev/null +++ b/backend/lib/crypto.js @@ -0,0 +1,107 @@ +/** + * OIDC Crypto Utility + * + * Provides AES-256-GCM encryption/decryption for OIDC client secrets. + * The symmetric key is derived from the application's RSA private key using + * HKDF (HMAC-based Key Derivation Function) with a fixed purpose label. + * + * IMPORTANT: If the RSA private key is rotated (e.g., /data/keys.json is deleted + * and regenerated), all encrypted OIDC client secrets will become unreadable. + * After RSA key rotation, you must re-save your OIDC configuration in the admin UI. + */ + +import crypto from "node:crypto"; +import { getPrivateKey } from "./config.js"; + +const PURPOSE_LABEL = "oidc-secret-encryption"; +const KEY_LENGTH = 32; // 256 bits for AES-256 +const IV_LENGTH = 12; // 96 bits for GCM (NIST recommended) + +/** + * Derive a stable AES-256 key from the RSA private key using HKDF. + * HKDF is purpose-bound, ensuring keys derived for different purposes are independent. + * + * @returns {Buffer} 32-byte AES key + */ +function deriveKey() { + const privateKey = getPrivateKey(); + if (!privateKey) { + throw new Error("RSA private key not available for OIDC secret encryption"); + } + + // Use HKDF to derive a purpose-bound key from the RSA key material + // This is more robust than raw SHA-256 hashing + return crypto.hkdfSync( + "sha256", + Buffer.from(privateKey, "utf8"), + Buffer.alloc(0), // salt: empty (key material already has high entropy) + Buffer.from(PURPOSE_LABEL, "utf8"), + KEY_LENGTH, + ); +} + +/** + * Encrypt a plaintext string using AES-256-GCM. + * Returns a base64-encoded string of format: iv:ciphertext:tag + * + * @param {string} plaintext + * @returns {string} base64-encoded encrypted blob + */ +function encryptSecret(plaintext) { + if (!plaintext) { + return plaintext; + } + + const key = deriveKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + + const tag = cipher.getAuthTag(); + + // Encode as: base64(iv) : base64(ciphertext) : base64(tag) + return `${iv.toString("base64")}:${encrypted.toString("base64")}:${tag.toString("base64")}`; +} + +/** + * Decrypt an encrypted string produced by encryptSecret(). + * + * @param {string} encrypted base64-encoded encrypted blob + * @returns {string} decrypted plaintext + * @throws {Error} if decryption fails (wrong key, tampered data, etc.) + */ +function decryptSecret(encrypted) { + if (!encrypted) { + return encrypted; + } + + const parts = encrypted.split(":"); + if (parts.length !== 3) { + throw new Error("Invalid encrypted secret format"); + } + + const [ivBase64, ciphertextBase64, tagBase64] = parts; + const key = deriveKey(); + const iv = Buffer.from(ivBase64, "base64"); + const ciphertext = Buffer.from(ciphertextBase64, "base64"); + const tag = Buffer.from(tagBase64, "base64"); + + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + + try { + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return decrypted.toString("utf8"); + } catch { + throw new Error("Failed to decrypt OIDC client secret — possible key rotation or data corruption"); + } +} + +export { encryptSecret, decryptSecret }; diff --git a/backend/lib/oidc-file-config.js b/backend/lib/oidc-file-config.js new file mode 100644 index 0000000000..285501fc96 --- /dev/null +++ b/backend/lib/oidc-file-config.js @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import { oidc as logger } from "../logger.js"; + +// Regex: only expand ${OIDC_*} references — prevents leaking DB passwords, etc. +const SAFE_ENV_VAR_RE = /\$\{(OIDC_[A-Z0-9_]+)\}/g; + +/** + * Validate that a URL uses HTTPS (mirrors enforceHttps in internal/oidc.js). + * Kept local to avoid a circular dependency: oidc.js → oidc-file-config.js → oidc.js. + * + * @param {string} url + * @returns {boolean} + */ +function isHttps(url) { + return typeof url === "string" && url.startsWith("https://"); +} + +/** + * Expand ${OIDC_*} placeholders in a string using process.env. + * Non-OIDC_ prefixed vars are NOT expanded (security: prevent exfiltration of + * DB passwords, tokens, etc. that may be present in the environment). + * + * @param {string} value + * @returns {string} + */ +function expandEnvVars(value) { + if (typeof value !== "string") { + return value; + } + return value.replace(SAFE_ENV_VAR_RE, (match, varName) => { + const resolved = process.env[varName]; + if (resolved === undefined) { + logger.warn(`OIDC file config: referenced env var "${varName}" is not set — using empty string`); + return ""; + } + return resolved; + }); +} + +/** + * Validate a single provider object. + * Returns an array of validation error strings (empty = valid). + * + * @param {Object} provider + * @returns {string[]} + */ +function validateProvider(provider) { + const errors = []; + if (!provider.id || typeof provider.id !== "string") { + errors.push("missing or invalid 'id'"); + } + if (!provider.name || typeof provider.name !== "string") { + errors.push("missing or invalid 'name'"); + } + if (!provider.discovery_url || typeof provider.discovery_url !== "string") { + errors.push("missing or invalid 'discovery_url'"); + } else if (!isHttps(provider.discovery_url)) { + errors.push(`'discovery_url' must use HTTPS (got: ${provider.discovery_url})`); + } + if (!provider.client_id || typeof provider.client_id !== "string") { + errors.push("missing or invalid 'client_id'"); + } + return errors; +} + +/** + * Load providers from a JSON config file. + * Invalid providers are skipped with a warning (fail-open for partial config). + * + * @param {string} filePath + * @returns {Object[]} array of validated, env-var-expanded provider objects with _source: "file" + */ +function loadFromFile(filePath) { + let parsed; + try { + const raw = fs.readFileSync(filePath, "utf8"); + parsed = JSON.parse(raw); + } catch (err) { + logger.error(`OIDC file config: failed to parse config file "${filePath}": ${err.message}`); + return []; + } + + if (!parsed || !Array.isArray(parsed.providers)) { + logger.error(`OIDC file config: config file "${filePath}" must have a top-level "providers" array`); + return []; + } + + const result = []; + for (const rawProvider of parsed.providers) { + // Expand ${OIDC_*} env vars in client_secret (and any other string fields) + const provider = { + ...rawProvider, + client_secret: expandEnvVars(rawProvider.client_secret || ""), + }; + + const errors = validateProvider(provider); + if (errors.length > 0) { + logger.warn(`OIDC file config: skipping provider "${provider.id || "(no id)"}" — ${errors.join("; ")}`); + continue; + } + + // Enforce auto_provision_role can never be "admin" from file config + if (provider.auto_provision_role && provider.auto_provision_role !== "user") { + logger.warn(`OIDC file config: provider "${provider.id}" has auto_provision_role="${provider.auto_provision_role}" — forced to "user"`); + } + + result.push({ + id: provider.id, + name: provider.name, + discovery_url: provider.discovery_url, + client_id: provider.client_id, + client_secret: provider.client_secret || "", + scopes: provider.scopes || "openid email profile", + enabled: provider.enabled !== false, // default true + use_par: provider.use_par || false, + auto_provision: provider.auto_provision || false, + auto_provision_role: "user", // Always "user" — never "admin" + claim_mapping: provider.claim_mapping || { + email: "email", + name: "name", + nickname: "preferred_username", + avatar: "picture", + }, + _source: "file", + }); + } + + logger.info(`OIDC file config: loaded ${result.length} provider(s) from "${filePath}"`); + return result; +} + +/** + * Load a single provider from OIDC_PROVIDER_* environment variables. + * Returns an empty array if OIDC_PROVIDER_ID is not set. + * + * @returns {Object[]} + */ +function loadFromEnvVars() { + const id = process.env.OIDC_PROVIDER_ID; + if (!id) { + return []; + } + + const provider = { + id, + name: process.env.OIDC_PROVIDER_NAME || id, + discovery_url: process.env.OIDC_PROVIDER_DISCOVERY_URL || "", + client_id: process.env.OIDC_PROVIDER_CLIENT_ID || "", + client_secret: process.env.OIDC_PROVIDER_CLIENT_SECRET || "", + scopes: process.env.OIDC_PROVIDER_SCOPES || "openid email profile", + enabled: process.env.OIDC_PROVIDER_ENABLED !== "false", // default true + use_par: process.env.OIDC_PROVIDER_USE_PAR === "true", + auto_provision: process.env.OIDC_PROVIDER_AUTO_PROVISION === "true", + auto_provision_role: "user", // Always "user" — never "admin" + claim_mapping: { + email: process.env.OIDC_PROVIDER_CLAIM_EMAIL || "email", + name: process.env.OIDC_PROVIDER_CLAIM_NAME || "name", + nickname: process.env.OIDC_PROVIDER_CLAIM_NICKNAME || "preferred_username", + avatar: process.env.OIDC_PROVIDER_CLAIM_AVATAR || "picture", + }, + _source: "file", + }; + + const errors = validateProvider(provider); + if (errors.length > 0) { + logger.warn(`OIDC env var config: skipping OIDC_PROVIDER_* provider "${id}" — ${errors.join("; ")}`); + return []; + } + + logger.info(`OIDC env var config: loaded provider "${provider.name}" (${provider.id}) from OIDC_PROVIDER_* env vars`); + return [provider]; +} + +// Module-level singleton — loaded once on first access (or via loadFileConfig()) +let cachedProviders = null; + +/** + * Eagerly load and cache file-based OIDC providers. + * Safe to call multiple times; only loads once per process lifetime. + * Call this during app startup for fail-fast validation. + */ +function loadFileConfig() { + if (cachedProviders !== null) { + return; + } + + const providers = []; + + // 1. File-based config — explicit path via OIDC_CONFIG_FILE, or default /data/oidc-providers.json + const configFilePath = process.env.OIDC_CONFIG_FILE || "/data/oidc-providers.json"; + if (fs.existsSync(configFilePath)) { + const fileProviders = loadFromFile(configFilePath); + providers.push(...fileProviders); + } else if (process.env.OIDC_CONFIG_FILE) { + // Only warn if user explicitly set the path and it doesn't exist + logger.warn(`OIDC file config: OIDC_CONFIG_FILE="${configFilePath}" not found — no file-based providers loaded`); + } + + // 2. Single-provider env vars (OIDC_PROVIDER_*) + // File config wins on ID conflict: only add env var provider if ID not already loaded + const fileIds = new Set(providers.map((p) => p.id)); + const envProviders = loadFromEnvVars(); + for (const p of envProviders) { + if (fileIds.has(p.id)) { + logger.warn(`OIDC env var config: provider "${p.id}" already defined in file config — env var provider skipped`); + } else { + providers.push(p); + } + } + + cachedProviders = providers; +} + +/** + * Get the cached list of file-sourced OIDC providers. + * Triggers a load on first call if not already loaded. + * + * @returns {Object[]} + */ +function getFileProviders() { + if (cachedProviders === null) { + loadFileConfig(); + } + return cachedProviders; +} + +/** + * Reset the cached providers (for testing purposes only). + * @internal + */ +function _resetCache() { + cachedProviders = null; +} + +export { loadFileConfig, getFileProviders, _resetCache }; diff --git a/backend/lib/validator/api.js b/backend/lib/validator/api.js index 6c738d5097..eddcc01839 100644 --- a/backend/lib/validator/api.js +++ b/backend/lib/validator/api.js @@ -7,6 +7,7 @@ const ajv = new Ajv({ allowUnionTypes: true, strict: false, coerceTypes: true, + validateFormats: false, }); /** diff --git a/backend/logger.js b/backend/logger.js index 2b60dbff7b..0bfea6971d 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -16,6 +16,7 @@ const importer = new signale.Signale({ scope: "Importer ", ...opts }); const setup = new signale.Signale({ scope: "Setup ", ...opts }); const ipRanges = new signale.Signale({ scope: "IP Ranges", ...opts }); const remoteVersion = new signale.Signale({ scope: "Remote Version", ...opts }); +const oidc = new signale.Signale({ scope: "OIDC ", ...opts }); const debug = (logger, ...args) => { if (isDebugMode()) { @@ -23,4 +24,4 @@ const debug = (logger, ...args) => { } }; -export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion }; +export { debug, global, migrate, express, access, nginx, ssl, certbot, importer, setup, ipRanges, remoteVersion, oidc }; diff --git a/backend/migrations/20260409000000_oidc_support.js b/backend/migrations/20260409000000_oidc_support.js new file mode 100644 index 0000000000..8ff419ee0f --- /dev/null +++ b/backend/migrations/20260409000000_oidc_support.js @@ -0,0 +1,62 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "oidc_support"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .alterTable("setting", (table) => { + // Increase value column to TEXT to support larger values + // (needed for encrypted client secrets stored in meta JSON) + table.text("value").alter(); + }) + .then(() => { + logger.info(`[${migrateName}] setting.value column altered to TEXT`); + }) + .then(() => { + // Add composite index on auth table for fast OIDC sub lookup + return knex.schema.alterTable("auth", (table) => { + table.index(["type", "secret", "user_id"], "idx_auth_type_secret_user"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] auth table index added`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + + return knex.schema + .alterTable("setting", (table) => { + table.string("value", 255).alter(); + }) + .then(() => { + logger.info(`[${migrateName}] setting.value column reverted to VARCHAR(255)`); + }) + .then(() => { + return knex.schema.alterTable("auth", (table) => { + table.dropIndex(["type", "secret", "user_id"], "idx_auth_type_secret_user"); + }); + }) + .then(() => { + logger.info(`[${migrateName}] auth table index removed`); + }); +}; + +export { up, down }; diff --git a/backend/package.json b/backend/package.json index e31bf22325..e25140b20c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,7 +10,8 @@ "lint": "biome lint", "prettier": "biome format --write .", "validate-schema": "node validate-schema.js", - "regenerate-config": "node scripts/regenerate-config" + "regenerate-config": "node scripts/regenerate-config", + "test": "node --test test/*.test.js" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.3.1", @@ -32,6 +33,7 @@ "mysql2": "^3.18.2", "node-rsa": "^1.1.1", "objection": "3.1.5", + "openid-client": "^6.8.2", "otplib": "^13.3.0", "path": "^0.12.7", "pg": "^8.19.0", diff --git a/backend/routes/main.js b/backend/routes/main.js index 94682cfba4..0a590241aa 100644 --- a/backend/routes/main.js +++ b/backend/routes/main.js @@ -11,6 +11,7 @@ import redirectionHostsRoutes from "./nginx/redirection_hosts.js"; import streamsRoutes from "./nginx/streams.js"; import reportsRoutes from "./reports.js"; import schemaRoutes from "./schema.js"; +import oidcRoutes from "./oidc.js"; import settingsRoutes from "./settings.js"; import tokensRoutes from "./tokens.js"; import usersRoutes from "./users.js"; @@ -47,6 +48,7 @@ router.use("/users", usersRoutes); router.use("/audit-log", auditLogRoutes); router.use("/reports", reportsRoutes); router.use("/settings", settingsRoutes); +router.use("/oidc", oidcRoutes); router.use("/version", versionRoutes); router.use("/nginx/proxy-hosts", proxyHostsRoutes); router.use("/nginx/redirection-hosts", redirectionHostsRoutes); diff --git a/backend/routes/oidc.js b/backend/routes/oidc.js new file mode 100644 index 0000000000..13ff26a760 --- /dev/null +++ b/backend/routes/oidc.js @@ -0,0 +1,664 @@ +import express from "express"; +import internalOidc from "../internal/oidc.js"; +import jwtdecode from "../lib/express/jwt-decode.js"; +import apiValidator from "../lib/validator/api.js"; +import { debug, oidc as logger } from "../logger.js"; +import { getValidationSchema } from "../schema/index.js"; + +const router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true, +}); + +/** + * Extract the public-facing origin (protocol + host + port) from the request. + * Handles reverse-proxy scenarios where nginx strips the port from the Host header. + * + * Priority: Origin header (AJAX) > X-Forwarded-Host > Host header with X-Forwarded-Port. + */ +function getOrigin(req) { + // AJAX calls include the Origin header with the full protocol://host:port + if (req.headers.origin) { + return req.headers.origin; + } + + const proto = req.headers["x-forwarded-proto"] || req.protocol; + + // X-Forwarded-Host typically preserves the original Host header including port + if (req.headers["x-forwarded-host"]) { + return `${proto}://${req.headers["x-forwarded-host"]}`; + } + + // Fallback: use Host header, appending X-Forwarded-Port if the port was stripped + let host = req.get("host") || req.hostname; + const fwdPort = req.headers["x-forwarded-port"]; + if (fwdPort && !host.includes(":")) { + const isDefault = (proto === "https" && fwdPort === "443") || (proto === "http" && fwdPort === "80"); + if (!isDefault) { + host = `${host}:${fwdPort}`; + } + } + + return `${proto}://${host}`; +} + +/** + * Allowed callback path prefixes. The frontend provides the full callback URL + * because behind a reverse proxy `getOrigin(req)` is unreliable for browser- + * initiated GET requests (no Origin header). + * + * Open-redirect risk is already mitigated by the OIDC provider's registered + * redirect_uri whitelist — the IdP will reject any URI not pre-configured. + * This server-side check adds defence-in-depth by ensuring the path points to + * one of our own OIDC callback endpoints. + */ +const ALLOWED_CALLBACK_PATHS = ["/api/oidc/callback", "/api/oidc/link-callback"]; + +/** + * Validate that a caller-supplied callback URL is well-formed and points to one + * of our known OIDC callback paths. + * + * TRUST BOUNDARY: This function validates the *path* only, not the origin/host. + * A URL like "https://evil.com/api/oidc/callback" passes validation. This is + * intentional — behind a reverse proxy the server cannot reliably determine the + * canonical origin. The OIDC provider's registered redirect_uri whitelist is the + * primary defence against open-redirect attacks; this check is defence-in-depth + * to ensure the path points to a legitimate callback endpoint. + * + * @param {string} callbackUrl - The caller-supplied callback URL + * @throws {Error} if the URL is malformed or targets an unexpected path + */ +function validateCallbackUrl(callbackUrl) { + let parsed; + try { + parsed = new URL(callbackUrl); + } catch { + throw new Error("Invalid callback URL"); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("callback_url must use http or https"); + } + const path = parsed.pathname.replace(/\/+$/, ""); // strip trailing slash + if (!ALLOWED_CALLBACK_PATHS.includes(path)) { + throw new Error("callback_url must target a valid OIDC callback path"); + } +} + +/** + * OIDC error code whitelist for safe HTML output. + * Unknown codes map to the generic message. + * SECURITY: Never reflect raw `error_description` from provider. + */ +const OIDC_ERROR_MESSAGES = { + access_denied: "Access was denied by the identity provider.", + invalid_request: "Invalid authentication request.", + unauthorized_client: "This application is not authorized with the identity provider.", + unsupported_response_type: "Unsupported response type.", + invalid_scope: "Invalid scope requested.", + server_error: "The identity provider encountered an error.", + temporarily_unavailable: "The identity provider is temporarily unavailable.", +}; + +const GENERIC_ERROR_MESSAGE = "Authentication failed. Please try again."; + +/** + * JSON.stringify with breakout prevention for embedding in ' sequence inside a JSON value + * cannot terminate the script element prematurely (XSS vector). + * @param {*} value + * @returns {string} + */ +function safeJsonStringify(value) { + return JSON.stringify(value).replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Render a safe callback HTML page. + * - Success (no 2FA): stores token in localStorage, redirects to / + * - Success (2FA): stores challenge token, redirects to /?2fa=true + * - Error: shows styled error message with Return to Login link + * + * SECURITY: All dynamic values use JSON.stringify() in JS context (safe escaping + * for , quotes, etc.) and htmlEncode() in HTML context. + * + * @param {Object} data + * @param {boolean} [data.success] + * @param {boolean} [data.requires2fa] + * @param {string} [data.token] + * @param {string} [data.expires] + * @param {string} [data.oidcProvider] - Provider ID for RP-initiated logout support + * @param {string} [data.challengeToken] + * @param {string} [data.errorMessage] - Must already be a SAFE pre-defined string + * @returns {string} + */ +function renderCallbackHtml(data) { + const { success, requires2fa, token, expires, oidcProvider, challengeToken, errorMessage } = data; + + let scriptBlock; + if (success && !requires2fa) { + // Store full session token and redirect to / + const tokenJson = safeJsonStringify(token); + const expiresJson = safeJsonStringify(expires); + const providerJson = safeJsonStringify(oidcProvider || null); + scriptBlock = ` + `; + } else if (requires2fa) { + // Store challenge token under a temporary key, redirect to login page with 2fa flag + const challengeJson = safeJsonStringify(challengeToken); + scriptBlock = ` + `; + } else { + // Error — show a safe, styled error page + const safeMessage = htmlEncode(errorMessage || GENERIC_ERROR_MESSAGE); + scriptBlock = ` + `; + // Override status default with safe HTML + return ` + + + + + Authentication + + + +
+
+

Authentication Failed

+

${safeMessage}

+ Return to Login +
+ +`; + } + + return ` + + + + + Authentication + + + +
+
+

Completing login…

+

Please wait while you are being redirected.

+ Return to Login +
+ ${scriptBlock} + +`; +} + +/** + * GET /api/oidc/providers + * + * Public — returns list of enabled OIDC providers (id + name only). + * Used by the login page to render "Login with X" buttons. + */ +router + .route("/providers") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + const providers = await internalOidc.getEnabledProviders(); + res.status(200).send(providers); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/callback + * + * Public — handles OIDC provider redirect callback. + * Returns a server-rendered HTML page (not JSON). + * + * SECURITY: Query params `error` and `error_description` from the provider + * are NEVER reflected raw. Only whitelisted error codes map to safe messages. + */ +router + .route("/callback") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + const { code, state, error: oidcError } = req.query; + + // Handle provider-sent error (e.g. user denied access) + if (oidcError) { + // Map to whitelisted safe message — NEVER reflect error_description + const safeMessage = OIDC_ERROR_MESSAGES[oidcError] || GENERIC_ERROR_MESSAGE; + const html = renderCallbackHtml({ errorMessage: safeMessage }); + return res.status(400).type("html").send(html); + } + + if (!code || !state) { + const html = renderCallbackHtml({ errorMessage: "Missing authorization code or state parameter." }); + return res.status(400).type("html").send(html); + } + + // Pass the full original URL so openid-client can extract all params + // (code, state, iss, session_state, etc.) for proper validation. + const fullCallbackUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + const result = await internalOidc.handleCallback(String(state), fullCallbackUrl); + + if (result.requires_2fa) { + const html = renderCallbackHtml({ + success: true, + requires2fa: true, + challengeToken: result.challenge_token, + }); + return res.status(200).type("html").send(html); + } + + const html = renderCallbackHtml({ + success: true, + requires2fa: false, + token: result.token, + expires: result.expires, + oidcProvider: result.oidc_provider || null, + }); + return res.status(200).type("html").send(html); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + // Show a generic error page — never expose internal error messages + const html = renderCallbackHtml({ errorMessage: GENERIC_ERROR_MESSAGE }); + return res.status(500).type("html").send(html); + } + }); + +/** + * POST /api/oidc/test-connection + * + * Admin only — tests OIDC provider connectivity by attempting discovery. + * Does not modify any stored configuration. + */ +router + .route("/test-connection") + .options((_, res) => { + res.sendStatus(204); + }) + .post(jwtdecode(), async (req, res, next) => { + try { + const data = await apiValidator(getValidationSchema("/oidc/test-connection", "post"), req.body); + const result = await internalOidc.testConnection(res.locals.access, data); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/identities + * + * Authenticated — returns the current user's linked OIDC identities. + */ +router + .route("/identities") + .options((_, res) => { + res.sendStatus(204); + }) + .get(jwtdecode(), async (req, res, next) => { + try { + const userId = res.locals.access.token.getUserId(); + const identities = await internalOidc.getUserIdentities(userId); + res.status(200).send(identities); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/link-callback + * + * Public — handles the OIDC provider redirect after account-linking authorization. + * Stores the authorization code (or error) in localStorage and redirects the + * browser back to /. The SPA picks up the pending result on load and completes + * the link via POST /api/oidc/link. + * + * MUST be registered before /:providerId/authorize to avoid Express + * matching "link-callback" as a :providerId param. + */ +router + .route("/link-callback") + .get((req, res) => { + const { error } = req.query; + // Whitelist known error codes — same as the login callback. + // SECURITY: Never reflect raw error/error_description from the IdP. + const safeError = error + ? (OIDC_ERROR_MESSAGES[error] ? String(error) : "unknown_error") + : null; + // Preserve the full query string so the SPA can forward all IdP params + // (code, state, iss, session_state, etc.) to POST /api/oidc/link for + // proper RFC 9207 issuer validation during the token exchange. + const qs = req.originalUrl.split("?")[1] || ""; + const html = ` +Linking... + +

Completing account linking...

+`; + res.type("html").send(html); + }); + +/** + * GET /api/oidc/:providerId/authorize + * + * Public — initiates OIDC authorization flow. + * Returns { authorize_url } for the frontend to redirect the browser to. + */ +router + .route("/:providerId/authorize") + .options((_, res) => { + res.sendStatus(204); + }) + .get(async (req, res, next) => { + try { + const { providerId } = req.params; + + // The frontend provides the callback URL via query param since it knows + // the real browser origin (protocol + host + port). Behind a reverse proxy, + // the backend cannot reliably reconstruct this from forwarded headers. + let callbackUrl; + if (req.query.callback_url) { + callbackUrl = String(req.query.callback_url); + validateCallbackUrl(callbackUrl); + } else { + const origin = getOrigin(req); + callbackUrl = `${origin}/api/oidc/callback`; + } + + const { authorizeUrl } = await internalOidc.buildAuthorizationUrl( + String(providerId), + callbackUrl, + ); + + res.status(200).send({ authorize_url: authorizeUrl }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/:providerId/logout + * + * Authenticated — returns RP-initiated logout URL for the given provider. + * Returns { logout_url } or null if provider doesn't support RP-initiated logout. + */ +router + .route("/:providerId/logout") + .options((_, res) => { + res.sendStatus(204); + }) + .get(jwtdecode(), async (req, res, next) => { + try { + const { providerId } = req.params; + const origin = getOrigin(req); + const postLogoutRedirectUri = `${origin}/`; + + const logoutUrl = await internalOidc.getLogoutUrl( + String(providerId), + postLogoutRedirectUri, + ); + + res.status(200).send({ logout_url: logoutUrl }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/:providerId/authorize-link + * + * Authenticated — initiates OIDC authorization for account linking. + * Returns { authorize_url, code_verifier, nonce, state, callback_url } so the + * frontend can store PKCE params and navigate to the authorize URL. + * + * The frontend provides callback_url via query param since it knows the real + * browser origin. Behind a reverse proxy the backend cannot reliably reconstruct + * this from forwarded headers. The URL is validated against the server origin. + */ +router + .route("/:providerId/authorize-link") + .options((_, res) => { + res.sendStatus(204); + }) + .get(jwtdecode(), async (req, res, next) => { + try { + const { providerId } = req.params; + let callbackUrl; + if (req.query.callback_url) { + callbackUrl = String(req.query.callback_url); + validateCallbackUrl(callbackUrl); + } else { + const origin = getOrigin(req); + callbackUrl = `${origin}/api/oidc/link-callback`; + } + const result = await internalOidc.buildLinkAuthorizationUrl( + res.locals.access, + String(providerId), + callbackUrl, + ); + res.status(200).send({ + authorize_url: result.authorizeUrl, + code_verifier: result.codeVerifier, + nonce: result.nonce, + state: result.state, + callback_url: callbackUrl, + }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * POST /api/oidc/link + * + * Authenticated — links an OIDC identity to the currently authenticated user. + * This is the ONLY way to link OIDC to an existing account (no unauthenticated email linking). + * + * The frontend forwards the full IdP callback query string (code, state, iss, + * session_state, etc.) so the backend can reconstruct the callback URL for + * proper RFC 9207 issuer validation during the token exchange. + */ +router + .route("/link") + .options((_, res) => { + res.sendStatus(204); + }) + .post(jwtdecode(), async (req, res, next) => { + try { + const data = await apiValidator(getValidationSchema("/oidc/link", "post"), req.body); + const callbackUrl = String(data.callback_url); + validateCallbackUrl(callbackUrl); + + await internalOidc.linkOidcIdentity( + res.locals.access, + data.provider_id, + data.code_verifier, + data.nonce, + callbackUrl, + data.state, + data.query_string || null, + ); + + res.status(200).send({ linked: true }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * DELETE /api/oidc/link/:providerId + * + * Authenticated — unlinks an OIDC identity from the current user. + */ +router + .route("/link/:providerId") + .options((_, res) => { + res.sendStatus(204); + }) + .delete(jwtdecode(), async (req, res, next) => { + try { + const { providerId } = req.params; + await internalOidc.unlinkOidcIdentity( + res.locals.access, + String(providerId), + ); + res.status(200).send({ unlinked: true }); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/config + * + * Admin only — returns OIDC configuration with redacted secrets. + */ +router + .route("/config") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/oidc/config + */ + .get(async (req, res, next) => { + try { + const config = await internalOidc.getConfig(res.locals.access); + res.status(200).send(config); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * PUT /api/oidc/config + */ + .put(async (req, res, next) => { + try { + // Strip read-only fields injected by GET that aren't in the PUT schema + if (Array.isArray(req.body?.providers)) { + for (const provider of req.body.providers) { + delete provider.source; + delete provider._source; + } + } + const data = await apiValidator(getValidationSchema("/oidc/config", "put"), req.body); + const result = await internalOidc.saveConfig(res.locals.access, data); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * GET /api/oidc/config/provider-users/:providerId + * + * Admin only — returns count of users linked to a specific provider. + */ +router + .route("/config/provider-users/:providerId") + .options((_, res) => { + res.sendStatus(204); + }) + .get(jwtdecode(), async (req, res, next) => { + try { + const { providerId } = req.params; + const result = await internalOidc.getProviderUserCount( + res.locals.access, + String(providerId), + ); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +export default router; diff --git a/backend/schema/components/user-object.json b/backend/schema/components/user-object.json index 7acd0a4290..3d1ecef040 100644 --- a/backend/schema/components/user-object.json +++ b/backend/schema/components/user-object.json @@ -55,6 +55,11 @@ "type": "string" } }, + "has_password_auth": { + "type": "boolean", + "description": "Whether the user has a local password set", + "example": true + }, "permissions": { "type": "object", "description": "Permissions if expanded in request", diff --git a/backend/schema/paths/oidc/callback/get.json b/backend/schema/paths/oidc/callback/get.json new file mode 100644 index 0000000000..cc4eb40793 --- /dev/null +++ b/backend/schema/paths/oidc/callback/get.json @@ -0,0 +1,47 @@ +{ + "operationId": "oidcCallback", + "summary": "OIDC provider redirect callback", + "description": "Handles the OIDC provider redirect after authentication. Returns a server-rendered HTML page that stores the session token in localStorage and redirects to the application. Query params are provided by the OIDC provider.", + "tags": ["public", "oidc"], + "parameters": [ + { + "in": "query", + "name": "code", + "required": false, + "schema": { "type": "string" }, + "description": "Authorization code from the OIDC provider" + }, + { + "in": "query", + "name": "state", + "required": false, + "schema": { "type": "string" }, + "description": "State JWT from the authorization request" + }, + { + "in": "query", + "name": "error", + "required": false, + "schema": { "type": "string" }, + "description": "OAuth error code from the provider (e.g. access_denied)" + } + ], + "responses": { + "200": { + "description": "HTML page that stores session and redirects", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + }, + "400": { + "description": "HTML error page for provider-sent errors", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/config/get.json b/backend/schema/paths/oidc/config/get.json new file mode 100644 index 0000000000..4e2511a4eb --- /dev/null +++ b/backend/schema/paths/oidc/config/get.json @@ -0,0 +1,39 @@ +{ + "operationId": "getOidcConfig", + "summary": "Get OIDC configuration", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "providers": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + } + } + }, + "examples": { + "default": { + "value": { + "providers": [] + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/config/provider-users/providerId/get.json b/backend/schema/paths/oidc/config/provider-users/providerId/get.json new file mode 100644 index 0000000000..f0caca6202 --- /dev/null +++ b/backend/schema/paths/oidc/config/provider-users/providerId/get.json @@ -0,0 +1,39 @@ +{ + "operationId": "getOidcProviderUserCount", + "summary": "Get count of users linked to a specific OIDC provider", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerId", + "required": true, + "schema": { "type": "string" }, + "description": "The OIDC provider ID" + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "total": { "type": "integer", "example": 3 }, + "oidc_only": { "type": "integer", "example": 1 } + } + }, + "examples": { + "default": { "value": { "total": 3, "oidc_only": 1 } } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/config/put.json b/backend/schema/paths/oidc/config/put.json new file mode 100644 index 0000000000..dcc3ef9a0e --- /dev/null +++ b/backend/schema/paths/oidc/config/put.json @@ -0,0 +1,165 @@ +{ + "operationId": "updateOidcConfig", + "summary": "Update OIDC configuration", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "requestBody": { + "description": "OIDC Configuration Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["providers"], + "properties": { + "providers": { + "type": "array", + "example": [], + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "discovery_url", "client_id", "client_secret", "enabled"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[a-z0-9-]+$", + "example": "authentik" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "example": "Authentik" + }, + "discovery_url": { + "type": "string", + "pattern": "^https://", + "minLength": 1, + "example": "https://auth.example.com/application/o/npm/.well-known/openid-configuration" + }, + "client_id": { + "type": "string", + "minLength": 1, + "example": "npm-client" + }, + "client_secret": { + "type": "string", + "example": "my-secret" + }, + "scopes": { + "type": "string", + "default": "openid email profile", + "example": "openid email profile" + }, + "enabled": { + "type": "boolean", + "example": true + }, + "use_par": { + "type": "boolean", + "default": false, + "example": false + }, + "auto_provision": { + "type": "boolean", + "default": false, + "example": false + }, + "auto_provision_role": { + "type": "string", + "enum": ["user"], + "default": "user", + "example": "user" + }, + "claim_mapping": { + "type": "object", + "additionalProperties": false, + "example": { "email": "email", "name": "name", "nickname": "preferred_username", "avatar": "picture" }, + "properties": { + "email": { + "type": "string", + "default": "email", + "example": "email" + }, + "name": { + "type": "string", + "default": "name", + "example": "name" + }, + "nickname": { + "type": "string", + "default": "preferred_username", + "example": "preferred_username" + }, + "avatar": { + "type": "string", + "default": "picture", + "example": "picture" + } + } + } + } + } + } + } + }, + "examples": { + "default": { + "value": { + "providers": [ + { + "id": "authentik", + "name": "Authentik", + "discovery_url": "https://auth.example.com/application/o/npm/.well-known/openid-configuration", + "client_id": "npm-client", + "client_secret": "my-secret", + "scopes": "openid email profile", + "enabled": true, + "use_par": false, + "auto_provision": false, + "auto_provision_role": "user" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "providers": { + "type": "array", + "example": [], + "items": { + "type": "object" + } + } + } + }, + "examples": { + "default": { + "value": { + "providers": [] + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/identities/get.json b/backend/schema/paths/oidc/identities/get.json new file mode 100644 index 0000000000..4b6dbbeea4 --- /dev/null +++ b/backend/schema/paths/oidc/identities/get.json @@ -0,0 +1,38 @@ +{ + "operationId": "getOidcIdentities", + "summary": "Get the current user's linked OIDC identities", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["user"] + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider_id": { "type": "string", "example": "authentik" }, + "provider_name": { "type": "string", "example": "Authentik" }, + "linked_on": { "type": "string", "format": "date-time", "example": "2025-01-15T10:30:00.000Z" } + } + } + }, + "examples": { + "default": { + "value": [ + { "provider_id": "authentik", "provider_name": "Authentik", "linked_on": "2025-01-15T10:30:00.000Z" } + ] + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/link-callback/get.json b/backend/schema/paths/oidc/link-callback/get.json new file mode 100644 index 0000000000..048b2868bb --- /dev/null +++ b/backend/schema/paths/oidc/link-callback/get.json @@ -0,0 +1,39 @@ +{ + "operationId": "oidcLinkCallback", + "summary": "OIDC link-flow callback page", + "description": "Renders an HTML page that stores the IdP callback result (full query string or whitelisted error code) in localStorage and redirects to /. The SPA picks up the pending result on load and completes the link via POST /api/oidc/link.", + "tags": ["public", "oidc"], + "parameters": [ + { + "in": "query", + "name": "code", + "required": false, + "schema": { "type": "string" }, + "description": "Authorization code from the OIDC provider" + }, + { + "in": "query", + "name": "state", + "required": false, + "schema": { "type": "string" }, + "description": "State JWT from the authorize-link request" + }, + { + "in": "query", + "name": "error", + "required": false, + "schema": { "type": "string" }, + "description": "OAuth error code from the provider (whitelisted before storage)" + } + ], + "responses": { + "200": { + "description": "HTML page that stores the callback result in localStorage and redirects to /", + "content": { + "text/html": { + "schema": { "type": "string" } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/link/post.json b/backend/schema/paths/oidc/link/post.json new file mode 100644 index 0000000000..432e1149a2 --- /dev/null +++ b/backend/schema/paths/oidc/link/post.json @@ -0,0 +1,97 @@ +{ + "operationId": "linkOidcIdentity", + "summary": "Link an OIDC identity to the authenticated user", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["user"] + } + ], + "requestBody": { + "description": "OIDC Link Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["provider_id", "code_verifier", "nonce", "query_string", "state", "callback_url"], + "properties": { + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "example": "authentik" + }, + "code_verifier": { + "type": "string", + "minLength": 1, + "example": "pkce-code-verifier" + }, + "nonce": { + "type": "string", + "minLength": 1, + "example": "random-nonce-value" + }, + "query_string": { + "type": "string", + "minLength": 1, + "description": "The full query string from the IdP callback redirect (code, state, iss, session_state, etc.). Forwarded as-is so the backend can reconstruct the callback URL for proper RFC 9207 issuer validation.", + "example": "code=auth-code&state=state-jwt&iss=https%3A%2F%2Fauth.example.com" + }, + "callback_url": { + "type": "string", + "minLength": 1, + "description": "The redirect_uri used in the authorization request. Must match the callback URL stored in the state JWT.", + "example": "https://npm.example.com/api/oidc/link-callback" + }, + "state": { + "type": "string", + "minLength": 1, + "description": "State JWT from the authorize-link request. Required for CSRF protection - binds the link request to the original authorization.", + "example": "eyJhbGciOiJIUzI1NiJ9..." + } + } + }, + "examples": { + "default": { + "value": { + "provider_id": "authentik", + "code_verifier": "pkce-code-verifier", + "nonce": "random-nonce-value", + "query_string": "code=auth-code&state=state-jwt&iss=https%3A%2F%2Fauth.example.com", + "state": "eyJhbGciOiJIUzI1NiJ9...", + "callback_url": "https://npm.example.com/api/oidc/link-callback" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "linked": { + "type": "boolean", + "example": true + } + } + }, + "examples": { + "default": { + "value": { + "linked": true + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/link/providerId/delete.json b/backend/schema/paths/oidc/link/providerId/delete.json new file mode 100644 index 0000000000..644ead1c4a --- /dev/null +++ b/backend/schema/paths/oidc/link/providerId/delete.json @@ -0,0 +1,38 @@ +{ + "operationId": "unlinkOidcIdentity", + "summary": "Unlink an OIDC identity from the authenticated user", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["user"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerId", + "required": true, + "schema": { "type": "string" }, + "description": "The OIDC provider ID to unlink" + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "unlinked": { "type": "boolean", "example": true } + } + }, + "examples": { + "default": { "value": { "unlinked": true } } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/providerId/authorize-link/get.json b/backend/schema/paths/oidc/providerId/authorize-link/get.json new file mode 100644 index 0000000000..43f81a36c3 --- /dev/null +++ b/backend/schema/paths/oidc/providerId/authorize-link/get.json @@ -0,0 +1,58 @@ +{ + "operationId": "oidcAuthorizeLink", + "summary": "Initiate OIDC authorization flow for account linking", + "description": "Returns an authorization URL plus PKCE code_verifier, nonce, state JWT, and the callback_url used as redirect_uri. The frontend stores PKCE params in sessionStorage, navigates to the authorize URL, and after the IdP redirects back to /api/oidc/link-callback the SPA completes the link via POST /api/oidc/link.", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["user"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerId", + "required": true, + "schema": { "type": "string" }, + "description": "The OIDC provider ID" + }, + { + "in": "query", + "name": "callback_url", + "required": false, + "schema": { "type": "string", "format": "uri" }, + "description": "Full link-callback URL (defaults to {origin}/api/oidc/link-callback). The frontend should provide this since it knows the real browser origin." + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "authorize_url": { "type": "string", "format": "uri", "example": "https://auth.example.com/authorize?client_id=...&redirect_uri=...&state=..." }, + "code_verifier": { "type": "string", "example": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }, + "nonce": { "type": "string", "example": "n-0S6_WzA2Mj" }, + "state": { "type": "string", "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." }, + "callback_url": { "type": "string", "format": "uri", "example": "https://npm.example.com/api/oidc/link-callback" } + } + }, + "examples": { + "default": { + "value": { + "authorize_url": "https://auth.example.com/authorize?client_id=...&redirect_uri=...&state=...", + "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + "nonce": "n-0S6_WzA2Mj", + "state": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "callback_url": "https://npm.example.com/api/oidc/link-callback" + } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/providerId/authorize/get.json b/backend/schema/paths/oidc/providerId/authorize/get.json new file mode 100644 index 0000000000..1bd8fcb3b9 --- /dev/null +++ b/backend/schema/paths/oidc/providerId/authorize/get.json @@ -0,0 +1,42 @@ +{ + "operationId": "oidcAuthorize", + "summary": "Initiate OIDC authorization flow for a provider", + "description": "Returns an authorization URL to redirect the browser to. The frontend provides the callback URL via query param since it knows the real browser origin.", + "tags": ["public", "oidc"], + "parameters": [ + { + "in": "path", + "name": "providerId", + "required": true, + "schema": { "type": "string" }, + "description": "The OIDC provider ID" + }, + { + "in": "query", + "name": "callback_url", + "required": false, + "schema": { "type": "string", "format": "uri" }, + "description": "Full callback URL for OIDC redirect (defaults to server-derived URL)" + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorize_url": { "type": "string", "format": "uri", "example": "https://auth.example.com/authorize?client_id=...&redirect_uri=...&state=..." } + } + }, + "examples": { + "default": { + "value": { "authorize_url": "https://auth.example.com/authorize?client_id=...&redirect_uri=...&state=..." } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/providerId/logout/get.json b/backend/schema/paths/oidc/providerId/logout/get.json new file mode 100644 index 0000000000..bc483dab0e --- /dev/null +++ b/backend/schema/paths/oidc/providerId/logout/get.json @@ -0,0 +1,40 @@ +{ + "operationId": "oidcLogout", + "summary": "Get RP-initiated logout URL for an OIDC provider", + "description": "Returns the provider's end-session URL for RP-initiated logout, or null if the provider doesn't support it.", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["user"] + } + ], + "parameters": [ + { + "in": "path", + "name": "providerId", + "required": true, + "schema": { "type": "string" }, + "description": "The OIDC provider ID" + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logout_url": { "type": ["string", "null"], "format": "uri", "example": "https://auth.example.com/oidc/logout?post_logout_redirect_uri=https://npm.example.com/" } + } + }, + "examples": { + "default": { + "value": { "logout_url": "https://auth.example.com/oidc/logout?post_logout_redirect_uri=https://npm.example.com/" } + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/providers/get.json b/backend/schema/paths/oidc/providers/get.json new file mode 100644 index 0000000000..af70de6e1e --- /dev/null +++ b/backend/schema/paths/oidc/providers/get.json @@ -0,0 +1,41 @@ +{ + "operationId": "getOidcProviders", + "summary": "Get enabled OIDC providers", + "tags": ["public", "oidc"], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "example": "authentik" + }, + "name": { + "type": "string", + "example": "Authentik" + } + } + } + }, + "examples": { + "default": { + "value": [ + { + "id": "authentik", + "name": "Authentik" + } + ] + } + } + } + } + } + } +} diff --git a/backend/schema/paths/oidc/test-connection/post.json b/backend/schema/paths/oidc/test-connection/post.json new file mode 100644 index 0000000000..3d2bcf13e8 --- /dev/null +++ b/backend/schema/paths/oidc/test-connection/post.json @@ -0,0 +1,110 @@ +{ + "operationId": "testOidcConnection", + "summary": "Test OIDC provider connectivity by attempting discovery", + "tags": ["oidc"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "requestBody": { + "description": "OIDC Test Connection Payload", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["discovery_url", "client_id", "client_secret", "scopes"], + "properties": { + "discovery_url": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "minLength": 1, + "example": "https://auth.example.com/.well-known/openid-configuration" + }, + "client_id": { + "type": "string", + "minLength": 1, + "example": "npm-client" + }, + "client_secret": { + "type": "string", + "example": "secret" + }, + "scopes": { + "type": "string", + "minLength": 1, + "example": "openid email profile" + }, + "provider_id": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "example": "authentik" + } + } + }, + "examples": { + "default": { + "value": { + "discovery_url": "https://auth.example.com/.well-known/openid-configuration", + "client_id": "npm-client", + "client_secret": "secret", + "scopes": "openid email profile", + "provider_id": "authentik" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "credentials": { + "type": "string", + "enum": ["valid", "invalid", "unsupported"], + "example": "valid" + }, + "credentials_message": { + "type": "string", + "example": "" + }, + "scopes_valid": { + "type": "boolean", + "example": true + }, + "unsupported_scopes": { + "type": "array", + "example": [], + "items": { "type": "string" } + } + } + }, + "examples": { + "default": { + "value": { + "success": true, + "credentials": "valid", + "credentials_message": "", + "scopes_valid": true, + "unsupported_scopes": [] + } + } + } + } + } + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..eeab4be473 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -60,6 +60,10 @@ "name": "tokens", "description": "Endpoints for managing authentication tokens" }, + { + "name": "oidc", + "description": "Endpoints for OpenID Connect authentication" + }, { "name": "users", "description": "Endpoints for managing users" @@ -357,6 +361,69 @@ "post": { "$ref": "./paths/users/userID/login/post.json" } + }, + "/oidc/providers": { + "get": { + "$ref": "./paths/oidc/providers/get.json" + } + }, + "/oidc/config": { + "get": { + "$ref": "./paths/oidc/config/get.json" + }, + "put": { + "$ref": "./paths/oidc/config/put.json" + } + }, + "/oidc/link": { + "post": { + "$ref": "./paths/oidc/link/post.json" + } + }, + "/oidc/test-connection": { + "post": { + "$ref": "./paths/oidc/test-connection/post.json" + } + }, + "/oidc/identities": { + "get": { + "$ref": "./paths/oidc/identities/get.json" + } + }, + "/oidc/link/{providerId}": { + "delete": { + "$ref": "./paths/oidc/link/providerId/delete.json" + } + }, + "/oidc/config/provider-users/{providerId}": { + "get": { + "$ref": "./paths/oidc/config/provider-users/providerId/get.json" + } + }, + "/oidc/{providerId}/authorize": { + "get": { + "$ref": "./paths/oidc/providerId/authorize/get.json" + } + }, + "/oidc/callback": { + "get": { + "$ref": "./paths/oidc/callback/get.json" + } + }, + "/oidc/{providerId}/logout": { + "get": { + "$ref": "./paths/oidc/providerId/logout/get.json" + } + }, + "/oidc/{providerId}/authorize-link": { + "get": { + "$ref": "./paths/oidc/providerId/authorize-link/get.json" + } + }, + "/oidc/link-callback": { + "get": { + "$ref": "./paths/oidc/link-callback/get.json" + } } } } diff --git a/backend/setup.js b/backend/setup.js index 84f42793ea..b55e1cf2df 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -97,6 +97,32 @@ const setupDefaultSettings = async () => { } }; +/** + * Creates default OIDC configuration if it doesn't already exist in the database + * + * @returns {Promise} + */ +const setupOidcSettings = async () => { + const row = await settingModel + .query() + .select("id") + .where({ id: "oidc-config" }) + .first(); + + if (!row?.id) { + await settingModel + .query() + .insert({ + id: "oidc-config", + name: "OIDC Configuration", + description: "OpenID Connect provider configuration", + value: "disabled", + meta: { providers: [] }, + }); + logger.info("Default OIDC settings added"); + } +}; + /** * Installs all Certbot plugins which are required for an installed certificate * @@ -163,4 +189,4 @@ const setupLogrotation = () => { return runLogrotate(); }; -export default () => setupDefaultUser().then(setupDefaultSettings).then(setupCertbotPlugins).then(setupLogrotation); +export default () => setupDefaultUser().then(setupDefaultSettings).then(setupOidcSettings).then(setupCertbotPlugins).then(setupLogrotation); diff --git a/backend/test/nginx.test.js b/backend/test/nginx.test.js new file mode 100644 index 0000000000..abf2c1ab8d --- /dev/null +++ b/backend/test/nginx.test.js @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import internalNginx from "../internal/nginx.js"; + +const hasDefault = internalNginx.advancedConfigHasDefaultLocation; + +describe("advancedConfigHasDefaultLocation", () => { + it("detects the exact config from issue #3678", () => { + const cfg = `location / { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; +}`; + assert.equal(hasDefault(cfg), true); + }); + + it("detects location / after other directives on separate lines", () => { + const cfg = `proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +location / { + proxy_pass http://backend; +}`; + assert.equal(hasDefault(cfg), true); + }); + + it("detects location / with nginx modifiers (=, ^~, ~, ~*)", () => { + assert.equal(hasDefault("location = / {"), true); + assert.equal(hasDefault("location ^~ / {"), true); + assert.equal(hasDefault("location ~ / {"), true); + assert.equal(hasDefault("location ~* / {"), true); + }); + + it("detects location / after a semicolon on the same line", () => { + assert.equal(hasDefault("set $test 1; location / {"), true); + }); + + it("ignores commented-out location / blocks", () => { + assert.equal(hasDefault("# location / {"), false); + const cfg = `proxy_set_header Host $host; +# location / { +# proxy_pass http://old; +# }`; + assert.equal(hasDefault(cfg), false); + }); + + it("detects real location / even when a commented one precedes it", () => { + const cfg = `# location / { old stuff } +location / { + proxy_pass http://new; +}`; + assert.equal(hasDefault(cfg), true); + }); + + it("does not match non-root location paths", () => { + assert.equal(hasDefault("location /api {"), false); + assert.equal(hasDefault("location = /api {"), false); + assert.equal(hasDefault("location ^~ /static {"), false); + assert.equal(hasDefault("location ~ \\.php$ {"), false); + }); + + it("returns false when no location block is present", () => { + assert.equal(hasDefault(""), false); + assert.equal(hasDefault("proxy_set_header Host $host;"), false); + }); +}); diff --git a/backend/test/oidc-file-config.test.js b/backend/test/oidc-file-config.test.js new file mode 100644 index 0000000000..14fbfc04f4 --- /dev/null +++ b/backend/test/oidc-file-config.test.js @@ -0,0 +1,474 @@ +/** + * Tests for backend/lib/oidc-file-config.js + * + * Covers: + * - File loading: valid JSON, invalid JSON, missing file + * - Required field validation: missing id, name, discovery_url, client_id + * - HTTPS enforcement on discovery_url + * - ${OIDC_*} env var expansion (allowed) and non-OIDC_ prefix (blocked) + * - Undefined env var → empty string + warning + * - Single-provider OIDC_PROVIDER_* env vars + * - _source: "file" on all loaded providers + * - auto_provision_role forced to "user" + * - Cache reset between tests + */ + +import { describe, it, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTempFile(content) { + const dir = os.tmpdir(); + const file = path.join(dir, `oidc-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(file, content, "utf8"); + return file; +} + +function makeValidProvider(overrides = {}) { + return { + id: "test-provider", + name: "Test Provider", + discovery_url: "https://auth.example.com/.well-known/openid-configuration", + client_id: "test-client", + client_secret: "test-secret", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Reset module cache between tests so env var / file changes take effect. +// node:test doesn't reload modules between tests, so we use _resetCache(). +// --------------------------------------------------------------------------- + +let resetCache; +let getFileProviders; +let loadFileConfig; + +before(async () => { + ({ _resetCache: resetCache, getFileProviders, loadFileConfig } = await import("../lib/oidc-file-config.js")); +}); + +beforeEach(() => { + // Reset the singleton so each test starts fresh + resetCache(); + + // Clean up env vars set by previous tests + delete process.env.OIDC_CONFIG_FILE; + delete process.env.OIDC_PROVIDER_ID; + delete process.env.OIDC_PROVIDER_NAME; + delete process.env.OIDC_PROVIDER_DISCOVERY_URL; + delete process.env.OIDC_PROVIDER_CLIENT_ID; + delete process.env.OIDC_PROVIDER_CLIENT_SECRET; + delete process.env.OIDC_PROVIDER_SCOPES; + delete process.env.OIDC_PROVIDER_ENABLED; + delete process.env.OIDC_PROVIDER_AUTO_PROVISION; + delete process.env.OIDC_PROVIDER_USE_PAR; + delete process.env.OIDC_TEST_SECRET; + delete process.env.OIDC_PROVIDER_CLAIM_EMAIL; + delete process.env.OIDC_PROVIDER_CLAIM_NAME; + delete process.env.OIDC_PROVIDER_CLAIM_NICKNAME; + delete process.env.OIDC_PROVIDER_CLAIM_AVATAR; +}); + +// --------------------------------------------------------------------------- +// File loading tests +// --------------------------------------------------------------------------- + +describe("File loading", () => { + it("returns empty array when OIDC_CONFIG_FILE is not set", () => { + const providers = getFileProviders(); + assert.deepStrictEqual(providers, []); + }); + + it("returns empty array when config file does not exist", () => { + process.env.OIDC_CONFIG_FILE = "/tmp/nonexistent-oidc-config-xyz.json"; + const providers = getFileProviders(); + assert.deepStrictEqual(providers, []); + }); + + it("returns empty array for invalid JSON", () => { + const file = makeTempFile("{ this is not json }"); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.deepStrictEqual(providers, []); + fs.unlinkSync(file); + }); + + it("returns empty array when file has no providers array", () => { + const file = makeTempFile(JSON.stringify({ something_else: true })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.deepStrictEqual(providers, []); + fs.unlinkSync(file); + }); + + it("loads valid providers from file", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider()], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0].id, "test-provider"); + fs.unlinkSync(file); + }); + + it("loads multiple providers from file", () => { + const file = makeTempFile(JSON.stringify({ + providers: [ + makeValidProvider({ id: "provider-a", name: "Provider A" }), + makeValidProvider({ id: "provider-b", name: "Provider B" }), + ], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 2); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// _source field +// --------------------------------------------------------------------------- + +describe("_source field", () => { + it("file providers have _source: 'file'", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0]._source, "file"); + fs.unlinkSync(file); + }); + + it("env var providers have _source: 'file'", () => { + process.env.OIDC_PROVIDER_ID = "env-provider"; + process.env.OIDC_PROVIDER_NAME = "Env Provider"; + process.env.OIDC_PROVIDER_DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"; + process.env.OIDC_PROVIDER_CLIENT_ID = "env-client"; + process.env.OIDC_PROVIDER_CLIENT_SECRET = "env-secret"; + const providers = getFileProviders(); + assert.strictEqual(providers[0]._source, "file"); + }); +}); + +// --------------------------------------------------------------------------- +// Required field validation +// --------------------------------------------------------------------------- + +describe("Required field validation", () => { + it("skips provider missing id", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ id: undefined })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("skips provider missing name", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ name: undefined })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("skips provider missing discovery_url", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ discovery_url: undefined })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("skips provider missing client_id", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ client_id: undefined })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("loads valid provider alongside invalid one", () => { + const file = makeTempFile(JSON.stringify({ + providers: [ + makeValidProvider({ id: undefined }), // invalid + makeValidProvider({ id: "valid-one", name: "Valid" }), // valid + ], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0].id, "valid-one"); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// HTTPS enforcement +// --------------------------------------------------------------------------- + +describe("HTTPS enforcement", () => { + it("skips provider with http:// discovery_url", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ discovery_url: "http://auth.example.com/.well-known/openid-configuration" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("skips provider with ftp:// discovery_url", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ discovery_url: "ftp://auth.example.com/openid-config" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + fs.unlinkSync(file); + }); + + it("accepts provider with https:// discovery_url", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider()], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 1); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// Env var expansion in client_secret +// --------------------------------------------------------------------------- + +describe("Env var expansion (${OIDC_*})", () => { + it("expands ${OIDC_*} references in client_secret", () => { + process.env.OIDC_TEST_SECRET = "resolved-secret-value"; + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ client_secret: "${OIDC_TEST_SECRET}" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].client_secret, "resolved-secret-value"); + fs.unlinkSync(file); + delete process.env.OIDC_TEST_SECRET; + }); + + it("does NOT expand non-OIDC_ prefixed env vars (security)", () => { + // Set a "dangerous" env var that should not be expanded + process.env.DB_MYSQL_PASSWORD = "do-not-leak-this"; + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ client_secret: "${DB_MYSQL_PASSWORD}" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + // The literal string should remain unexpanded (regex only matches OIDC_*) + assert.strictEqual(providers[0].client_secret, "${DB_MYSQL_PASSWORD}"); + fs.unlinkSync(file); + delete process.env.DB_MYSQL_PASSWORD; + }); + + it("resolves undefined OIDC_ var to empty string", () => { + delete process.env.OIDC_UNDEFINED_VAR; + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ client_secret: "${OIDC_UNDEFINED_VAR}" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].client_secret, ""); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// Single-provider OIDC_PROVIDER_* env vars +// --------------------------------------------------------------------------- + +describe("Single-provider env vars (OIDC_PROVIDER_*)", () => { + it("returns empty array when OIDC_PROVIDER_ID is not set", () => { + const providers = getFileProviders(); + assert.deepStrictEqual(providers, []); + }); + + it("loads provider from OIDC_PROVIDER_* env vars", () => { + process.env.OIDC_PROVIDER_ID = "authentik"; + process.env.OIDC_PROVIDER_NAME = "Authentik"; + process.env.OIDC_PROVIDER_DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"; + process.env.OIDC_PROVIDER_CLIENT_ID = "npm-client"; + process.env.OIDC_PROVIDER_CLIENT_SECRET = "super-secret"; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0].id, "authentik"); + assert.strictEqual(providers[0].name, "Authentik"); + assert.strictEqual(providers[0].client_id, "npm-client"); + assert.strictEqual(providers[0].client_secret, "super-secret"); + }); + + it("uses default scopes when OIDC_PROVIDER_SCOPES is not set", () => { + process.env.OIDC_PROVIDER_ID = "test"; + process.env.OIDC_PROVIDER_DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"; + process.env.OIDC_PROVIDER_CLIENT_ID = "client"; + const providers = getFileProviders(); + assert.strictEqual(providers[0].scopes, "openid email profile"); + }); + + it("skips env var provider when OIDC_PROVIDER_DISCOVERY_URL is missing", () => { + process.env.OIDC_PROVIDER_ID = "broken"; + process.env.OIDC_PROVIDER_CLIENT_ID = "client"; + // No OIDC_PROVIDER_DISCOVERY_URL + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + }); + + it("skips env var provider when OIDC_PROVIDER_DISCOVERY_URL uses http://", () => { + process.env.OIDC_PROVIDER_ID = "broken"; + process.env.OIDC_PROVIDER_DISCOVERY_URL = "http://auth.example.com/openid-config"; + process.env.OIDC_PROVIDER_CLIENT_ID = "client"; + const providers = getFileProviders(); + assert.strictEqual(providers.length, 0); + }); + + it("file config wins when env var provider has same ID as file provider", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ id: "same-id", name: "From File" })], + })); + process.env.OIDC_CONFIG_FILE = file; + process.env.OIDC_PROVIDER_ID = "same-id"; + process.env.OIDC_PROVIDER_NAME = "From Env"; + process.env.OIDC_PROVIDER_DISCOVERY_URL = "https://auth.example.com/.well-known/openid-configuration"; + process.env.OIDC_PROVIDER_CLIENT_ID = "env-client"; + const providers = getFileProviders(); + // Only 1 provider (file wins), and it should be the file version + assert.strictEqual(providers.length, 1); + assert.strictEqual(providers[0].name, "From File"); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// auto_provision_role enforcement +// --------------------------------------------------------------------------- + +describe("auto_provision_role enforcement", () => { + it("forces auto_provision_role to 'user' even when file specifies 'admin'", () => { + const file = makeTempFile(JSON.stringify({ + providers: [makeValidProvider({ auto_provision_role: "admin" })], + })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].auto_provision_role, "user"); + fs.unlinkSync(file); + }); + + it("sets auto_provision_role to 'user' by default", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].auto_provision_role, "user"); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +describe("Provider defaults", () => { + it("enabled defaults to true when not specified", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].enabled, true); + fs.unlinkSync(file); + }); + + it("enabled is false when explicitly set to false", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider({ enabled: false })] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].enabled, false); + fs.unlinkSync(file); + }); + + it("use_par defaults to false", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].use_par, false); + fs.unlinkSync(file); + }); + + it("auto_provision defaults to false", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].auto_provision, false); + fs.unlinkSync(file); + }); + + it("scopes default to 'openid email profile'", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.strictEqual(providers[0].scopes, "openid email profile"); + fs.unlinkSync(file); + }); + + it("claim_mapping defaults are applied when not provided", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const providers = getFileProviders(); + assert.deepStrictEqual(providers[0].claim_mapping, { + email: "email", + name: "name", + nickname: "preferred_username", + avatar: "picture", + }); + fs.unlinkSync(file); + }); +}); + +// --------------------------------------------------------------------------- +// Caching +// --------------------------------------------------------------------------- + +describe("Caching", () => { + it("returns the same array reference on repeated calls (singleton)", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const first = getFileProviders(); + const second = getFileProviders(); + assert.strictEqual(first, second); + fs.unlinkSync(file); + }); + + it("_resetCache allows reloading", () => { + const file = makeTempFile(JSON.stringify({ providers: [makeValidProvider()] })); + process.env.OIDC_CONFIG_FILE = file; + const first = getFileProviders(); + assert.strictEqual(first.length, 1); + + // Reset and change env var + resetCache(); + delete process.env.OIDC_CONFIG_FILE; + const second = getFileProviders(); + assert.deepStrictEqual(second, []); + fs.unlinkSync(file); + }); +}); diff --git a/backend/test/oidc.test.js b/backend/test/oidc.test.js new file mode 100644 index 0000000000..9e667d4c9d --- /dev/null +++ b/backend/test/oidc.test.js @@ -0,0 +1,589 @@ +/** + * OIDC module unit tests + * + * Tests cover: + * - Crypto: encrypt/decrypt round-trip, different inputs > different outputs, invalid ciphertext throws + * - HTML encoding: XSS prevention in htmlEncode helper + * - OIDC error whitelisting: known error codes map to safe messages, unknown codes produce generic message + * - HTTPS enforcement: non-HTTPS URLs are rejected + */ + +import { describe, it, mock, before, after } from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +// --------------------------------------------------------------------------- +// Crypto tests — test the encrypt/decrypt logic directly without the RSA key +// dependency. We extract the pure algorithmic logic and test it with a known key. +// --------------------------------------------------------------------------- + +describe("OIDC AES-256-GCM crypto (pure algorithm tests)", () => { + const IV_LENGTH = 12; + const TAG_LENGTH = 16; + + // Replicate the encrypt/decrypt logic from lib/crypto.js with a test key + function encryptWithKey(plaintext, key) { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("base64")}:${encrypted.toString("base64")}:${tag.toString("base64")}`; + } + + function decryptWithKey(encryptedStr, key) { + const parts = encryptedStr.split(":"); + if (parts.length !== 3) { + throw new Error("Invalid encrypted secret format"); + } + const [ivB64, ctB64, tagB64] = parts; + const iv = Buffer.from(ivB64, "base64"); + const ciphertext = Buffer.from(ctB64, "base64"); + const tag = Buffer.from(tagB64, "base64"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString("utf8"); + } + + const testKey = crypto.randomBytes(32); // Stable key for this test run + + it("encrypt then decrypt returns original string", () => { + const plaintext = "my-super-secret-client-secret-12345"; + const encrypted = encryptWithKey(plaintext, testKey); + const decrypted = decryptWithKey(encrypted, testKey); + assert.equal(decrypted, plaintext); + }); + + it("different inputs produce different outputs", () => { + const enc1 = encryptWithKey("secret-a", testKey); + const enc2 = encryptWithKey("secret-b", testKey); + assert.notEqual(enc1, enc2); + }); + + it("same plaintext produces different ciphertext each call (random IV)", () => { + const enc1 = encryptWithKey("same-secret", testKey); + const enc2 = encryptWithKey("same-secret", testKey); + assert.notEqual(enc1, enc2); // Different IVs > different ciphertext + }); + + it("encrypted output has exactly 3 base64 segments separated by colons", () => { + const encrypted = encryptWithKey("test", testKey); + const parts = encrypted.split(":"); + assert.equal(parts.length, 3); + // Verify each part is valid base64 (non-empty) + for (const part of parts) { + assert.ok(part.length > 0); + } + }); + + it("decryption throws on tampered ciphertext", () => { + const encrypted = encryptWithKey("real-secret", testKey); + const parts = encrypted.split(":"); + // Tamper with the ciphertext part + const tamperedCt = Buffer.from(parts[1], "base64"); + tamperedCt[0] ^= 0xff; // Flip bits + const tampered = `${parts[0]}:${tamperedCt.toString("base64")}:${parts[2]}`; + assert.throws(() => decryptWithKey(tampered, testKey), /unsupported state|unable to authenticate|decrypt/i); + }); + + it("decryption throws on wrong format (too few segments)", () => { + assert.throws( + () => decryptWithKey("notvalidformat", testKey), + /Invalid encrypted secret format/, + ); + }); + + it("decryption throws on wrong key", () => { + const encrypted = encryptWithKey("secret", testKey); + const wrongKey = crypto.randomBytes(32); + assert.throws(() => decryptWithKey(encrypted, wrongKey)); + }); + + it("HKDF key derivation produces consistent output for same inputs", () => { + const keyMaterial = Buffer.from("test-rsa-key-material", "utf8"); + const key1 = crypto.hkdfSync("sha256", keyMaterial, Buffer.alloc(0), Buffer.from("oidc-secret-encryption", "utf8"), 32); + const key2 = crypto.hkdfSync("sha256", keyMaterial, Buffer.alloc(0), Buffer.from("oidc-secret-encryption", "utf8"), 32); + assert.deepEqual(Buffer.from(key1), Buffer.from(key2)); + }); + + it("HKDF with different purpose labels produces different keys", () => { + const keyMaterial = Buffer.from("test-rsa-key-material", "utf8"); + const key1 = crypto.hkdfSync("sha256", keyMaterial, Buffer.alloc(0), Buffer.from("oidc-secret-encryption", "utf8"), 32); + const key2 = crypto.hkdfSync("sha256", keyMaterial, Buffer.alloc(0), Buffer.from("other-purpose", "utf8"), 32); + assert.notDeepEqual(Buffer.from(key1), Buffer.from(key2)); + }); +}); + +// --------------------------------------------------------------------------- +// HTML encoding tests — verify XSS prevention helper +// --------------------------------------------------------------------------- + +describe("HTML encoding (XSS prevention)", () => { + // Replicate htmlEncode from routes/oidc.js for isolation testing + function htmlEncode(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + it("encodes ampersand", () => { + assert.equal(htmlEncode("a & b"), "a & b"); + }); + + it("encodes angle brackets", () => { + assert.equal(htmlEncode(""), "</script>"); + }); + + it("encodes double quotes", () => { + assert.equal(htmlEncode('say "hello"'), "say "hello""); + }); + + it("encodes single quotes", () => { + assert.equal(htmlEncode("it's fine"), "it's fine"); + }); + + it("encodes XSS payload", () => { + const xss = ""; + const encoded = htmlEncode(xss); + assert.ok(!encoded.includes("<")); + assert.ok(!encoded.includes(">")); + assert.ok(!encoded.includes('"')); + assert.ok(encoded.includes("<img")); + }); + + it("leaves safe characters unchanged", () => { + const safe = "Hello World 123 !@#$%^*()-_=+[]{}|;:,.?/"; + assert.equal(htmlEncode(safe), safe); + }); + + it("handles empty string", () => { + assert.equal(htmlEncode(""), ""); + }); + + it("converts non-string inputs to string first", () => { + assert.equal(htmlEncode(42), "42"); + assert.equal(htmlEncode(null), "null"); + }); +}); + +// --------------------------------------------------------------------------- +// OIDC error code whitelisting tests +// Verifies that raw provider error params are never reflected +// --------------------------------------------------------------------------- + +describe("OIDC error code whitelisting (XSS/injection prevention)", () => { + // Replicate the whitelist from routes/oidc.js + const OIDC_ERROR_MESSAGES = { + access_denied: "Access was denied by the identity provider.", + invalid_request: "Invalid authentication request.", + unauthorized_client: "This application is not authorized with the identity provider.", + unsupported_response_type: "Unsupported response type.", + invalid_scope: "Invalid scope requested.", + server_error: "The identity provider encountered an error.", + temporarily_unavailable: "The identity provider is temporarily unavailable.", + }; + const GENERIC_ERROR_MESSAGE = "Authentication failed. Please try again."; + + function getSafeErrorMessage(errorCode) { + return OIDC_ERROR_MESSAGES[errorCode] || GENERIC_ERROR_MESSAGE; + } + + it("maps known error code access_denied to safe message", () => { + assert.equal(getSafeErrorMessage("access_denied"), "Access was denied by the identity provider."); + }); + + it("maps known error code server_error to safe message", () => { + assert.equal(getSafeErrorMessage("server_error"), "The identity provider encountered an error."); + }); + + it("maps all 7 known error codes to a non-empty message", () => { + for (const code of Object.keys(OIDC_ERROR_MESSAGES)) { + const msg = getSafeErrorMessage(code); + assert.ok(msg.length > 0, `Empty message for code: ${code}`); + assert.notEqual(msg, GENERIC_ERROR_MESSAGE, `${code} should have custom message`); + } + }); + + it("returns generic message for unknown error code", () => { + assert.equal(getSafeErrorMessage("totally_custom_error"), GENERIC_ERROR_MESSAGE); + }); + + it("returns generic message for XSS injection attempt in error code", () => { + const xssAttempt = ""; + const result = getSafeErrorMessage(xssAttempt); + assert.equal(result, GENERIC_ERROR_MESSAGE); + assert.ok(!result.includes("