File size: 8,325 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';

const dataDir = path.resolve(__dirname, '../data');

// JWT_SECRET is always managed by the server β€” auto-generated on first start and
// persisted to data/.jwt_secret. Use the admin panel to rotate it; do not set it
// via environment variable (env var would override a rotation on next restart).
const jwtSecretFile = path.join(dataDir, '.jwt_secret');
let _jwtSecret: string;

try {
  _jwtSecret = fs.readFileSync(jwtSecretFile, 'utf8').trim();
} catch {
  _jwtSecret = crypto.randomBytes(32).toString('hex');
  try {
    if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
    fs.writeFileSync(jwtSecretFile, _jwtSecret, { mode: 0o600 });
    console.log('Generated and saved JWT secret to', jwtSecretFile);
  } catch (writeErr: unknown) {
    console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
    console.warn('Sessions will reset on server restart.');
  }
}

// export let so TypeScript's CJS output keeps exports.JWT_SECRET live
// (generates `exports.JWT_SECRET = JWT_SECRET = newVal` inside updateJwtSecret)
export let JWT_SECRET = _jwtSecret;

// Called by the admin rotate-jwt-secret endpoint to update the in-process
// binding that all middleware and route files reference.
export function updateJwtSecret(newSecret: string): void {
  JWT_SECRET = newSecret;
}

// ENCRYPTION_KEY is used to derive at-rest encryption keys for stored secrets
// (API keys, MFA TOTP secrets, SMTP password, OIDC client secret, etc.).
// Keeping it separate from JWT_SECRET means you can rotate session tokens without
// invalidating all stored encrypted data, and vice-versa.
//
// Resolution order:
//   1. ENCRYPTION_KEY env var β€” explicit, always takes priority.
//   2. data/.encryption_key file β€” present on any install that has started at
//      least once (written automatically by cases 1b and 3 below).
//   3. data/.jwt_secret β€” one-time fallback for existing installs upgrading
//      without a pre-set ENCRYPTION_KEY. The value is immediately persisted to
//      data/.encryption_key so JWT rotation can never break decryption later.
//   4. Auto-generated β€” fresh install with none of the above; persisted to
//      data/.encryption_key.
const encKeyFile = path.join(dataDir, '.encryption_key');
let _encryptionKey: string = process.env.ENCRYPTION_KEY || '';

if (_encryptionKey) {
  // Env var is set explicitly β€” persist it to file so the value survives
  // container restarts even if the env var is later removed.
  try {
    if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
    fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
  } catch {
    // Non-fatal: env var is the source of truth when set.
  }
} else {
  // Try the dedicated key file first (covers all installs after first start).
  try {
    _encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim();
  } catch {
    // File not found β€” first start on an existing or fresh install.
  }

  if (!_encryptionKey) {
    // One-time migration: existing install upgrading for the first time.
    // Use the JWT secret as the encryption key and immediately write it to
    // .encryption_key so future JWT rotations cannot break decryption.
    try {
      _encryptionKey = fs.readFileSync(jwtSecretFile, 'utf8').trim();
      console.warn('WARNING: ENCRYPTION_KEY is not set. Falling back to JWT secret for at-rest encryption.');
      console.warn('The value has been persisted to data/.encryption_key β€” JWT rotation is now safe.');
    } catch {
      // JWT secret not found β€” must be a fresh install.
    }
  }

  if (!_encryptionKey) {
    // Fresh install β€” auto-generate a dedicated key.
    _encryptionKey = crypto.randomBytes(32).toString('hex');
  }

  // Persist whatever key was resolved so subsequent starts skip the fallback chain.
  try {
    if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
    fs.writeFileSync(encKeyFile, _encryptionKey, { mode: 0o600 });
    console.log('Encryption key persisted to', encKeyFile);
  } catch (writeErr: unknown) {
    console.warn('WARNING: Could not persist encryption key to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
    console.warn('Set ENCRYPTION_KEY env var to avoid losing access to encrypted secrets on restart.');
  }
}

export const ENCRYPTION_KEY = _encryptionKey;

// DEFAULT_LANGUAGE sets the language shown on the login page before the user
// selects one. Only applies when the user has no saved language preference.
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
  console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
}
export const DEFAULT_LANGUAGE = SUPPORTED_LANG_CODES.includes(rawDefaultLang) ? rawDefaultLang : 'en';

// SESSION_DURATION controls how long a TREK session (the `trek_session` JWT
// cookie) stays valid before re-login is required. Accepts ms-style strings:
// '1h', '12h', '7d', '30d', '90d', etc. It applies to BOTH the JWT `exp` claim
// and the cookie `maxAge`, so the two never drift apart. Invalid values warn at
// startup and fall back to the default. Does not affect the short-lived MFA
// challenge token or MCP OAuth tokens β€” those keep their own TTL.
const DEFAULT_SESSION_DURATION = '24h';
const DURATION_UNITS_MS: Record<string, number> = {
  ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, y: 31_557_600_000,
};
function parseDurationMs(value: string): number | null {
  const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i.exec(value.trim());
  if (!m) return null;
  const n = parseFloat(m[1]);
  if (!Number.isFinite(n) || n <= 0) return null;
  return n * DURATION_UNITS_MS[(m[2] || 'ms').toLowerCase()];
}
const rawSessionDuration = process.env.SESSION_DURATION?.trim() || DEFAULT_SESSION_DURATION;
const parsedSessionMs = parseDurationMs(rawSessionDuration);
if (parsedSessionMs == null) {
  console.warn(`SESSION_DURATION="${rawSessionDuration}" is not a valid duration (use e.g. 1h, 7d, 30d). Falling back to "${DEFAULT_SESSION_DURATION}".`);
}
/** Human-readable session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATION : rawSessionDuration;
/** Session length in milliseconds β€” used for the cookie `maxAge`. */
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
/** Session length in seconds β€” passed to `jwt.sign({ expiresIn })` (number = seconds). */
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);

// SESSION_DURATION_REMEMBER is the session length used when the user ticks
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
// format and fallback behavior as SESSION_DURATION.
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rawRememberDuration);
if (parsedRememberMs == null) {
  console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
}
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
/** "Remember me" session length in milliseconds β€” used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds β€” passed to `jwt.sign({ expiresIn })`. */
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);