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 +1 -1
- package/src/auth/token-secret.js +122 -0
- package/src/auth/token.js +4 -23
- package/test/token-secret.test.js +209 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
const
|
|
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
|
+
});
|