Add admin identity module: better-auth, per-app permissions, invitations (#12)
Jenkins Production Deployment
Jenkins Production Deployment
Reviewed-on: #12 Co-authored-by: Patrick Müller <mail@pmueller.me> Co-committed-by: Patrick Müller <mail@pmueller.me>
This commit was merged in pull request #12.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user