import * as crypto from 'crypto'; import * as dotenv from 'dotenv'; import logger from '../../middleware/logger.js'; dotenv.config(); /** * One place that reads the admin module's environment. Both admin.auth.ts * (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the * same origin list, and a second parser would drift from this one. * * Read this before changing the environment handling below: several security * properties depend on it, and they are deliberately arranged to fail *safe*. * * `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour. * Only the explicit values 'development' and 'test' relax anything; anything * else - including NODE_ENV being unset, which is exactly what a fresh Plesk * vhost gives you - is treated as production. The inverse arrangement is a * trap: it degrades the cookie domain, the CORS origin list and the signing * key all at once, and every one of those failures is silent. * * The signing key is never allowed to be a known constant. In dev, an unset * BETTER_AUTH_SECRET becomes a random per-process value: sessions do not * survive a restart, which is mildly annoying and much better than a default * secret that can be copied out of this file and used against production. */ const nodeEnv = process.env.NODE_ENV; // Explicitly relaxed environments. Everything else, unset included, is strict. const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test'; export const isProd = !isRelaxedEnv; const required = (name: string, devDefault: string): string => { const value = process.env[name]; if (value) { return value; } if (isProd) { logger.error( `Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` + 'set NODE_ENV=development for local work)' ); throw new Error(`${name} must be set unless NODE_ENV is development or test`); } return devDefault; }; export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000'); export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002'); // 32+ random bytes; better-auth signs cookies and reset tokens with it. // Rotating it invalidates every session, which is why it is not derived. // There is no hardcoded fallback on purpose: a constant committed here would // be a published signing key the moment someone deploys without setting it. export const BETTER_AUTH_SECRET = required( 'BETTER_AUTH_SECRET', crypto.randomBytes(48).toString('base64') ); // Passkeys are bound to this: a credential registered for "nachklang.art" // works on every *.nachklang.art host, one registered for "localhost" only // works in dev. Changing it invalidates every registered passkey. export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost'); const parseList = (value: string | undefined, fallback: string[]): string[] => { const parsed = (value || '') .split(',') .map(entry => entry.trim()) .filter(entry => entry.length > 0); return parsed.length > 0 ? parsed : fallback; }; // The apps whose frontends may talk to /admin/* with credentials. export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, '')); // Kept in sync by construction rather than by three separate lists: the admin // app itself always counts, and dev adds the local ports. export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([ ADMIN_APP_URL.replace(/\/$/, ''), ...APP_ORIGINS ])); /** * The header the reverse proxy puts the real client IP in, and the proxy hops * to trust when reading it. * * This matters more than it looks. better-auth does not know about Express's * `trust proxy`; it reads the request itself. If it cannot resolve a client IP * it falls back to a single shared bucket ("no-trusted-ip") for the whole * process - and /sign-in/* carries a default of 3 requests per 10 seconds, so * one noisy client would lock every user out of every app. * * Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value * x-forwarded-for outright (it cannot tell which hop is the client), which is * exactly the case that produces that shared bucket. Set it to the address or * CIDR of Plesk's nginx. Conversely, listing a header the proxy does not * overwrite lets a client set its own IP and mint itself an unlimited * brute-force budget - so the default is the single header nginx sets, not a * permissive list. */ /** * `CLIENT_IP_HEADERS=none` trusts no header at all. * * This is the escape hatch for the one case where the wrong setting is worse * than no setting: if the proxy turns out NOT to overwrite the header we are * trusting, any client can send it and mint itself an unlimited brute-force * budget against /sign-in. Falling back to the shared bucket is bad (one noisy * client can lock the organisation out for ten seconds at a time) but it is * bad in a way that fails closed, and it can be reverted from the environment * without a deploy. * * Reach for it only after a check has actually failed - `SELECT ipAddress FROM * session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote * sign-in - and take it back out once the header is configured. * * An empty or unset value still means "use the default", not "trust nothing": * a stray blank line in a .env must not silently change how requests are * bucketed. Only the explicit word does that. */ const TRUST_NO_HEADER = 'none'; export const TRUST_NO_CLIENT_IP_HEADER = (process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER; // An empty array is what better-auth reads as "no headers": it only falls back // to its own default when the option is absent, and `[]` is truthy. export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER ? [] : parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']); export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []); if (isProd && TRUST_NO_CLIENT_IP_HEADER) { logger.warn( 'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' + 'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' + 'for everyone combined. This is the safe fallback, not a destination: configure the ' + 'header the proxy actually sets and remove it.' ); } else if (isProd && TRUSTED_PROXY_IPS.length === 0) { logger.warn( 'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' + `${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` + 'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' + 'a "no-trusted-ip" row means this is happening. A single-value header needs no ' + 'trusted proxies, so this warning is expected on a plain single-proxy setup.' ); } export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || ''; /** * Whether to write invitation links to the log. An invitation link is a live * account-creation credential, so this is an explicit opt-in rather than * something inferred from NODE_ENV: local work needs it (the mail relay is * usually off, and only the token's hash is stored, so there is otherwise no * way to walk the accept flow), and production must never have it. * * Refused outright in strict mode, so setting it in a production .env by * accident fails at boot instead of quietly filling the log with credentials. */ export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd; if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) { logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens'); }