javascript-solid-server 0.0.137 → 0.0.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.137",
3
+ "version": "0.0.138",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * TOKEN_SECRET resolution.
3
+ *
4
+ * Extracted from token.js so it can be unit-tested without pulling in the
5
+ * full auth graph (solid-oidc, nostr, webid-tls), which does module-level
6
+ * work that keeps the node:test event loop busy.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ export const DEFAULT_SECRET_PATH = path.join(os.homedir(), '.jss', 'token.secret');
15
+
16
+ // Tighten permissions on POSIX, best-effort. No-op on Windows (ACLs) and
17
+ // on read-only filesystems — we never want perm-tightening to block using
18
+ // an otherwise-valid secret.
19
+ function chmodBestEffort(target, mode) {
20
+ try {
21
+ fs.chmodSync(target, mode);
22
+ } catch {
23
+ // Intentionally swallow — perms are defensive hardening, not required.
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Read a persisted secret from `filePath`, or generate one and write it
29
+ * (with dir mode 0700 and file mode 0600) if the file is missing.
30
+ *
31
+ * Read-first: if the file already exists and is non-empty we return it
32
+ * without trying to mkdir or tighten the containing directory. Deployments
33
+ * with a pre-provisioned secret on a read-only filesystem boot cleanly.
34
+ *
35
+ * Concurrent-startup safe: new secrets are written to a per-process temp
36
+ * file in the same directory and `renameSync`'d into place, so another
37
+ * process reading the target never sees a half-written file. If a peer
38
+ * process won the rename we fall back to reading their value.
39
+ *
40
+ * Anything other than ENOENT on the initial read (permission denied,
41
+ * corrupt FS, …) propagates.
42
+ */
43
+ export function readOrWritePersistedSecret(filePath = DEFAULT_SECRET_PATH) {
44
+ const dir = path.dirname(filePath);
45
+
46
+ // Fast path: pre-existing non-empty file. We do not mkdir the parent
47
+ // dir here, and perm-tightening is best-effort (chmodBestEffort swallows
48
+ // all errors), so a pre-provisioned secret on a read-only filesystem
49
+ // still boots cleanly.
50
+ try {
51
+ const existing = fs.readFileSync(filePath, 'utf8').trim();
52
+ if (existing) {
53
+ chmodBestEffort(dir, 0o700);
54
+ chmodBestEffort(filePath, 0o600);
55
+ return existing;
56
+ }
57
+ } catch (e) {
58
+ if (e.code !== 'ENOENT') throw e;
59
+ }
60
+
61
+ // Slow path: create it. Only touch the FS with writes from here on.
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ chmodBestEffort(dir, 0o700);
64
+
65
+ const generated = crypto.randomBytes(32).toString('hex');
66
+ // Atomic write: fully write a temp file, then rename into place. On
67
+ // POSIX the rename is atomic, so concurrent readers see either the old
68
+ // content or the new complete content — never a half-written file.
69
+ const tmpPath = `${filePath}.${crypto.randomBytes(8).toString('hex')}.tmp`;
70
+ try {
71
+ fs.writeFileSync(tmpPath, generated, { mode: 0o600 });
72
+ fs.renameSync(tmpPath, filePath);
73
+ } catch (e) {
74
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
75
+ throw e;
76
+ }
77
+ chmodBestEffort(filePath, 0o600);
78
+
79
+ // Multiple processes racing each produce a different secret; only the
80
+ // last renamer's value sticks on disk. Re-read so every process ends up
81
+ // using the winning secret and token verification stays consistent.
82
+ const persisted = fs.readFileSync(filePath, 'utf8').trim();
83
+ return persisted || generated;
84
+ }
85
+
86
+ /**
87
+ * Resolve the token secret.
88
+ *
89
+ * 1. TOKEN_SECRET env → use it.
90
+ * 2. Else read/create ~/.jss/token.secret.
91
+ * 3. On file-write failure: hard-exit in production, ephemeral secret otherwise.
92
+ *
93
+ * Console I/O is injected so tests can assert log behaviour without spamming
94
+ * the real console; defaults to the real console.
95
+ */
96
+ export function resolveTokenSecret({
97
+ env = process.env,
98
+ secretPath = DEFAULT_SECRET_PATH,
99
+ log = console,
100
+ exit = (code) => process.exit(code),
101
+ } = {}) {
102
+ if (env.TOKEN_SECRET) return env.TOKEN_SECRET;
103
+
104
+ try {
105
+ const s = readOrWritePersistedSecret(secretPath);
106
+ log.warn(`Using persisted TOKEN_SECRET at ${secretPath} (set TOKEN_SECRET env var to override).`);
107
+ return s;
108
+ } catch (e) {
109
+ if (env.NODE_ENV === 'production') {
110
+ const code = e?.code ? ` [${e.code}]` : '';
111
+ log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} could not be read or created${code} (${e.message}).`);
112
+ log.error(`Set TOKEN_SECRET explicitly, or grant the necessary access to ${path.dirname(secretPath)}.`);
113
+ exit(1);
114
+ // `exit` is injectable; if a caller stubs it out we must not silently
115
+ // return undefined and let downstream code use an invalid secret.
116
+ throw new Error(`Failed to resolve TOKEN_SECRET in production: ${e.message}`);
117
+ }
118
+ const ephemeral = crypto.randomBytes(32).toString('hex');
119
+ log.warn(`WARNING: Could not persist TOKEN_SECRET (${e.message}). Using ephemeral secret; tokens will not survive restarts.`);
120
+ return ephemeral;
121
+ }
122
+ }
package/src/auth/token.js CHANGED
@@ -11,30 +11,11 @@ import crypto from 'crypto';
11
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
12
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
13
  import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
14
+ import { resolveTokenSecret } from './token-secret.js';
14
15
 
15
- // Secret for signing tokens
16
- // SECURITY: In production, TOKEN_SECRET must be set via environment variable
17
- const getSecret = () => {
18
- if (process.env.TOKEN_SECRET) {
19
- return process.env.TOKEN_SECRET;
20
- }
21
-
22
- // In production (NODE_ENV=production), require explicit secret
23
- if (process.env.NODE_ENV === 'production') {
24
- console.error('SECURITY ERROR: TOKEN_SECRET environment variable must be set in production');
25
- console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
26
- process.exit(1);
27
- }
28
-
29
- // In development, generate a random secret per process (tokens won't survive restarts)
30
- const devSecret = crypto.randomBytes(32).toString('hex');
31
- console.warn('WARNING: No TOKEN_SECRET set. Using random secret (tokens will not survive restarts).');
32
- console.warn('Set TOKEN_SECRET environment variable for persistent tokens.');
33
- return devSecret;
34
- };
35
-
36
- // Initialize secret once at module load
37
- const SECRET = getSecret();
16
+ // Initialize secret once at module load. See token-secret.js for the
17
+ // resolution order (env ~/.jss/token.secret exit-or-ephemeral).
18
+ const SECRET = resolveTokenSecret();
38
19
 
39
20
  /**
40
21
  * Create a simple token for a WebID
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Unit tests for TOKEN_SECRET resolution (src/auth/token-secret.js).
3
+ *
4
+ * Covers #280: TOKEN_SECRET auto-persists on first run rather than hard-exiting.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+ import path from 'path';
12
+ import {
13
+ readOrWritePersistedSecret,
14
+ resolveTokenSecret,
15
+ DEFAULT_SECRET_PATH,
16
+ } from '../src/auth/token-secret.js';
17
+
18
+ describe('readOrWritePersistedSecret', () => {
19
+ let tmpDir;
20
+ let secretPath;
21
+
22
+ before(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-token-secret-'));
24
+ secretPath = path.join(tmpDir, '.jss', 'token.secret');
25
+ });
26
+
27
+ after(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it('generates + persists a secret when the file is missing', () => {
32
+ const s = readOrWritePersistedSecret(secretPath);
33
+ assert.strictEqual(typeof s, 'string');
34
+ assert.strictEqual(s.length, 64); // 32 bytes, hex-encoded
35
+ assert.strictEqual(fs.readFileSync(secretPath, 'utf8').trim(), s);
36
+ });
37
+
38
+ it('returns the same secret on subsequent calls', () => {
39
+ const first = readOrWritePersistedSecret(secretPath);
40
+ const second = readOrWritePersistedSecret(secretPath);
41
+ assert.strictEqual(first, second);
42
+ });
43
+
44
+ it('enforces tight permissions on POSIX (skipped on Windows)', { skip: process.platform === 'win32' }, () => {
45
+ const stat = fs.statSync(secretPath);
46
+ assert.strictEqual(stat.mode & 0o777, 0o600, 'secret file should be mode 0600');
47
+ const dirStat = fs.statSync(path.dirname(secretPath));
48
+ assert.strictEqual(dirStat.mode & 0o777, 0o700, 'secret dir should be mode 0700');
49
+ });
50
+
51
+ it('propagates errors other than ENOENT', () => {
52
+ // Use a regular file as the would-be parent directory — mkdirSync then
53
+ // fails with ENOTDIR synchronously. Portable across OSes.
54
+ const blockerFile = path.join(tmpDir, 'blocker-file');
55
+ fs.writeFileSync(blockerFile, 'not a dir');
56
+ const unwritable = path.join(blockerFile, '.jss', 'token.secret');
57
+ assert.throws(() => readOrWritePersistedSecret(unwritable));
58
+ });
59
+
60
+ it('recovers when the secret file already exists but is empty', () => {
61
+ // Simulates a concurrent or interrupted persistence case: the file
62
+ // is present (so the fast path falls through the trim-empty check)
63
+ // but carries no usable secret yet. tmp-file + renameSync repairs
64
+ // it by overwriting atomically.
65
+ const p = path.join(tmpDir, 'empty', '.jss', 'token.secret');
66
+ fs.mkdirSync(path.dirname(p), { recursive: true });
67
+ fs.writeFileSync(p, '');
68
+ const s = readOrWritePersistedSecret(p);
69
+ assert.strictEqual(s.length, 64);
70
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
71
+ });
72
+
73
+ it('tightens permissions when the file already exists with loose mode', { skip: process.platform === 'win32' }, () => {
74
+ const p = path.join(tmpDir, 'loose', '.jss', 'token.secret');
75
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o755 });
76
+ fs.writeFileSync(p, 'a'.repeat(64), { mode: 0o644 });
77
+ readOrWritePersistedSecret(p);
78
+ assert.strictEqual(fs.statSync(p).mode & 0o777, 0o600);
79
+ assert.strictEqual(fs.statSync(path.dirname(p)).mode & 0o777, 0o700);
80
+ });
81
+
82
+ it('reads a pre-existing secret even when the parent dir is not writable', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, () => {
83
+ // Simulates a read-only deployment: secret provisioned ahead of time,
84
+ // parent dir not writable for the current user. Must not block startup.
85
+ const p = path.join(tmpDir, 'readonly-parent', '.jss', 'token.secret');
86
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
87
+ const expected = 'b'.repeat(64);
88
+ fs.writeFileSync(p, expected);
89
+ fs.chmodSync(path.dirname(p), 0o500); // r-x, no write
90
+ try {
91
+ const s = readOrWritePersistedSecret(p);
92
+ assert.strictEqual(s, expected);
93
+ } finally {
94
+ fs.chmodSync(path.dirname(p), 0o700); // let after()'s rmSync clean up
95
+ }
96
+ });
97
+ });
98
+
99
+ describe('resolveTokenSecret', () => {
100
+ let tmpDir;
101
+
102
+ before(() => {
103
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-resolve-secret-'));
104
+ });
105
+
106
+ after(() => {
107
+ fs.rmSync(tmpDir, { recursive: true, force: true });
108
+ });
109
+
110
+ const silentLog = { warn: () => {}, error: () => {} };
111
+
112
+ it('prefers TOKEN_SECRET env var', () => {
113
+ const s = resolveTokenSecret({
114
+ env: { TOKEN_SECRET: 'from-env' },
115
+ secretPath: path.join(tmpDir, 'unused', 'token.secret'),
116
+ log: silentLog,
117
+ });
118
+ assert.strictEqual(s, 'from-env');
119
+ });
120
+
121
+ it('persists a generated secret when env is unset', () => {
122
+ const p = path.join(tmpDir, 'persist', 'token.secret');
123
+ const s = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
124
+ assert.strictEqual(s.length, 64);
125
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
126
+ });
127
+
128
+ it('returns the same persisted secret on the next call', () => {
129
+ const p = path.join(tmpDir, 'persist-twice', 'token.secret');
130
+ const first = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
131
+ const second = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
132
+ assert.strictEqual(first, second);
133
+ });
134
+
135
+ // Build an unwritable path by planting a regular file where the helper
136
+ // would try to mkdir a directory. mkdirSync then fails synchronously.
137
+ function buildUnwritable(name) {
138
+ const blocker = path.join(tmpDir, name, 'blocker-file');
139
+ fs.mkdirSync(path.dirname(blocker), { recursive: true });
140
+ fs.writeFileSync(blocker, 'not a dir');
141
+ return path.join(blocker, '.jss', 'token.secret');
142
+ }
143
+
144
+ it('hard-exits in production when persistence fails', () => {
145
+ let exitCode;
146
+ assert.throws(() => {
147
+ resolveTokenSecret({
148
+ env: { NODE_ENV: 'production' },
149
+ secretPath: buildUnwritable('prod'),
150
+ log: silentLog,
151
+ exit: (code) => { exitCode = code; }, // stubbed — doesn't actually terminate
152
+ });
153
+ });
154
+ // exit(1) must still have been invoked even though we throw afterwards,
155
+ // so a non-stubbed production process actually terminates.
156
+ assert.strictEqual(exitCode, 1);
157
+ });
158
+
159
+ it('throws after exit so a stubbed exit() cannot leak undefined downstream', () => {
160
+ // Regression: earlier versions returned undefined "for tests" after
161
+ // calling exit(), which could let callers continue with an invalid
162
+ // secret when exit is stubbed.
163
+ assert.throws(
164
+ () => resolveTokenSecret({
165
+ env: { NODE_ENV: 'production' },
166
+ secretPath: buildUnwritable('no-leak'),
167
+ log: silentLog,
168
+ exit: () => {},
169
+ }),
170
+ /TOKEN_SECRET/
171
+ );
172
+ });
173
+
174
+ it('production error message references the actual secret directory', () => {
175
+ const secretPath = buildUnwritable('custom-path');
176
+ const errors = [];
177
+ assert.throws(() => {
178
+ resolveTokenSecret({
179
+ env: { NODE_ENV: 'production' },
180
+ secretPath,
181
+ log: { warn: () => {}, error: (msg) => errors.push(msg) },
182
+ exit: () => {},
183
+ });
184
+ });
185
+ assert.ok(
186
+ errors.some(m => m.includes(path.dirname(secretPath))),
187
+ `expected an error to mention ${path.dirname(secretPath)}, got: ${errors.join(' | ')}`
188
+ );
189
+ });
190
+
191
+ it('falls back to an ephemeral secret outside production when persistence fails', () => {
192
+ const s = resolveTokenSecret({
193
+ env: {},
194
+ secretPath: buildUnwritable('dev'),
195
+ log: silentLog,
196
+ exit: () => { throw new Error('exit should not be called in dev') },
197
+ });
198
+ assert.strictEqual(typeof s, 'string');
199
+ assert.strictEqual(s.length, 64);
200
+ });
201
+ });
202
+
203
+ describe('DEFAULT_SECRET_PATH', () => {
204
+ it('is absolute and platform-native', () => {
205
+ assert.ok(path.isAbsolute(DEFAULT_SECRET_PATH));
206
+ assert.ok(DEFAULT_SECRET_PATH.includes('.jss'));
207
+ assert.ok(DEFAULT_SECRET_PATH.includes('token.secret'));
208
+ });
209
+ });