Auto-generate and persist TOKEN_SECRET on first run (#280) - #281
Conversation
…erver#280) Previously a production boot without a TOKEN_SECRET env var was fatal. A beginner starting JSS under pm2 with NODE_ENV=production (a natural default) was greeted by a SECURITY ERROR that pointed at a node command they might not even have in their shell path. Resolution order is now: 1. TOKEN_SECRET env var — unchanged. 2. ~/.jss/token.secret — auto-generated on first run, chmod 600. 3. Hard-exit only when (1) is unset, (2) can't be written, AND NODE_ENV=production. Dev falls back to an ephemeral random secret. The persisted file lives under os.homedir() (cross-platform via path.join) rather than under the pod's dataRoot. urlToPath() does not filter dotfiles, so a <dataRoot>/.jss/token.secret would be served over HTTP; staying outside dataRoot sidesteps that. Resolution logic lives in src/auth/token-secret.js so it can be unit tested without importing the full auth graph (solid-oidc, nostr, webid-tls, which do module-level work that stalls node:test). Operators who set TOKEN_SECRET explicitly see no behaviour change. Fixes JavaScriptSolidServer#280
There was a problem hiding this comment.
Pull request overview
This PR removes the “hard-exit if TOKEN_SECRET is unset in production” onboarding footgun by introducing a persisted, auto-generated TOKEN_SECRET stored outside the data root (defaulting to ~/.jss/token.secret), and adds unit tests for the new resolution logic.
Changes:
- Add
src/auth/token-secret.jsto resolve TOKEN_SECRET via env → persisted secret file → (prod exit | dev ephemeral fallback). - Update
src/auth/token.jsto use the new resolver at module load. - Add
test/token-secret.test.jscovering persistence behavior, permission expectations, and failure-mode behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/token-secret.test.js | Adds unit coverage for token secret resolution and persistence semantics. |
| src/auth/token.js | Switches token signing secret initialization to the new resolver. |
| src/auth/token-secret.js | Implements persisted TOKEN_SECRET generation/reading and production/dev fallback logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const generated = crypto.randomBytes(32).toString('hex'); | ||
| fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); | ||
| // mode is honoured on POSIX; silently ignored on Windows (uses ACLs). |
There was a problem hiding this comment.
readOrWritePersistedSecret has a race if multiple JSS processes start at the same time (e.g., pm2/cluster). Two processes can both observe ENOENT, generate different secrets, and the later writer wins, leaving the first process using a non-persisted secret and causing token verification failures across processes. Consider making the write atomic (e.g., create the file with an exclusive flag like wx/O_EXCL, and on EEXIST re-read the file).
| const generated = crypto.randomBytes(32).toString('hex'); | |
| fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); | |
| // mode is honoured on POSIX; silently ignored on Windows (uses ACLs). | |
| const generated = crypto.randomBytes(32).toString('hex'); | |
| fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); | |
| try { | |
| // mode is honoured on POSIX; silently ignored on Windows (uses ACLs). | |
| // Use an exclusive create so concurrent processes cannot overwrite each | |
| // other's freshly generated secrets. | |
| fs.writeFileSync(filePath, generated, { mode: 0o600, flag: 'wx' }); | |
| return generated; | |
| } catch (e) { | |
| if (e.code !== 'EEXIST') throw e; | |
| } | |
| const existing = fs.readFileSync(filePath, 'utf8').trim(); | |
| if (existing) return existing; | |
| // Preserve prior behaviour for an existing but empty file. |
| const generated = crypto.randomBytes(32).toString('hex'); | ||
| fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); | ||
| // mode is honoured on POSIX; silently ignored on Windows (uses ACLs). | ||
| fs.writeFileSync(filePath, generated, { mode: 0o600 }); |
There was a problem hiding this comment.
The code comments/docs indicate the secret dir/file are created with 0700/0600, but mkdirSync(..., { mode }) and writeFileSync(..., { mode }) only apply on creation and won't tighten permissions if the directory/file already exists (and mode on mkdirSync can be ignored when the path exists). If the goal is to enforce these permissions, consider explicitly chmodSyncing path.dirname(filePath) and filePath after ensuring they exist (POSIX only).
| } catch (e) { | ||
| if (env.NODE_ENV === 'production') { | ||
| log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} is not writable (${e.message}).`); | ||
| log.error('Set TOKEN_SECRET explicitly, or grant write access to ~/.jss/.'); |
There was a problem hiding this comment.
This production error path hard-codes ~/.jss/. in the remediation message, but secretPath is injectable (and even for the default path, os.homedir() may not correspond to the literal ~ shown). Consider referencing the actual directory derived from secretPath (e.g., path.dirname(secretPath)) so the guidance matches the path used in the preceding error message.
| log.error('Set TOKEN_SECRET explicitly, or grant write access to ~/.jss/.'); | |
| log.error(`Set TOKEN_SECRET explicitly, or grant write access to ${path.dirname(secretPath)}.`); |
1. Race-safe write. Persisted-secret write now uses an exclusive flag
('wx'); on EEXIST we re-read the file. Two JSS processes starting at
the same time converge on the same secret rather than clobbering.
2. Enforce permissions on pre-existing dir/file. mkdirSync({mode}) and
writeFileSync({mode}) only apply on creation. Chmod dir to 0700 and
file to 0600 on every call, best-effort (swallowing EPERM/ENOTSUP on
Windows).
3. Production error message now references path.dirname(secretPath)
rather than a hardcoded "~/.jss/", matching the injectable path.
Adds three test cases: recovery from an empty pre-existing file, perm
tightening on a looser pre-existing layout, and the error-message
pointing at the actual directory.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Always ensure the dir exists and is 0700 — enforce perms on every call, | ||
| // not only when we're the one creating it. mkdirSync({mode}) is only | ||
| // applied on creation, so an existing dir with looser mode needs chmod. | ||
| const dir = path.dirname(filePath); | ||
| fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); | ||
| chmodBestEffort(dir, 0o700); |
There was a problem hiding this comment.
readOrWritePersistedSecret() unconditionally calls mkdirSync/chmod on the parent dir before attempting to read an existing secret. This can make startup fail (and in production, exit) on read-only filesystems or when the dir is not writable, even if token.secret already exists and is readable. Consider trying to read/return the existing file first, and only creating/chmod’ing the directory when the file is missing/empty; also treat chmod failures as best-effort so they don’t block using an already-present secret.
| const generated = crypto.randomBytes(32).toString('hex'); | ||
| try { | ||
| // Exclusive create — concurrent processes can't overwrite each other's | ||
| // freshly generated secrets. mode is honoured on POSIX; Windows ignores | ||
| // it (uses ACLs). | ||
| fs.writeFileSync(filePath, generated, { mode: 0o600, flag: 'wx' }); | ||
| chmodBestEffort(filePath, 0o600); | ||
| return generated; | ||
| } catch (e) { | ||
| if (e.code !== 'EEXIST') throw e; | ||
| } | ||
|
|
||
| // Another process won the race — read what they wrote. | ||
| const existing = fs.readFileSync(filePath, 'utf8').trim(); | ||
| if (existing) { | ||
| chmodBestEffort(filePath, 0o600); | ||
| return existing; | ||
| } | ||
| // Same fallback as before for a pre-existing empty file. | ||
| fs.writeFileSync(filePath, generated, { mode: 0o600 }); | ||
| chmodBestEffort(filePath, 0o600); |
There was a problem hiding this comment.
The “safe against multiple processes starting in parallel” claim isn’t fully upheld: writeFileSync(..., { flag: 'wx' }) creates the file before it is fully written, so another process that hits EEXIST can read an empty/partial file and then overwrite it in the fallback path, leading to different secrets across processes. To make convergence robust, consider an atomic write strategy (e.g., write to a temp file in the same directory and renameSync into place, or retry reads until non-empty instead of overwriting).
| log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} is not writable (${e.message}).`); | ||
| log.error(`Set TOKEN_SECRET explicitly, or grant write access to ${path.dirname(secretPath)}.`); | ||
| exit(1); | ||
| return undefined; // for tests that stub `exit` |
There was a problem hiding this comment.
After calling exit(1), the function returns undefined “for tests”. If a caller injects an exit stub that doesn’t terminate (or this is used programmatically), downstream code can continue with an undefined secret and fail later in less obvious ways. Consider throwing an error after invoking exit, or returning a valid (but clearly marked) value to prevent accidental continuation when exit is overridden.
| return undefined; // for tests that stub `exit` | |
| throw new Error(`Failed to resolve TOKEN_SECRET in production: ${e.message}`); |
1. Read-first. Previous revision did mkdirSync + chmodSync on the parent dir unconditionally at entry, so a pre-provisioned secret on a read-only filesystem would make startup fail. Now the fast path reads the existing file and returns it before touching the dir at all; chmod of the dir/file runs best-effort on that path so perm-tighten failures can't block using a valid secret. 2. Atomic write via temp + rename. writeFileSync(flag: 'wx') created the file before writing content, so another process hitting EEXIST could observe an empty file. Switch to writing a per-process temp file and renameSync'ing it into place, which on POSIX is atomic — concurrent readers always see either the old content or the new complete content, never a half-written file. 3. Throw after exit() in production. exit is injectable; if a caller stubbed it (e.g. tests) the old code returned undefined and let downstream use an invalid secret. Now we still call exit(1) for real production processes, then raise so a stubbed exit can't leak. 4. chmodBestEffort now swallows all chmod errors (not just EPERM/ENOTSUP) — perm tightening is defensive, never load-bearing. Adds two tests: throw-after-stubbed-exit (regression guard for JavaScriptSolidServer#3) and read-from-a-not-writable-parent-dir (regression guard for JavaScriptSolidServer#1).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Fast path: pre-existing non-empty file. No mkdir/chmod attempt on the | ||
| // parent dir here — a read-only FS must not fail this path. | ||
| try { |
There was a problem hiding this comment.
The comment says the fast path returns an existing secret "without trying to mkdir or tighten the containing directory", but the code immediately calls chmodBestEffort() on both the directory and file. Please update the comment to match the behavior (or move the chmod calls out if the intent is truly read-only/no-metadata-touch in this path).
| log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} is not writable (${e.message}).`); | ||
| log.error(`Set TOKEN_SECRET explicitly, or grant write access to ${path.dirname(secretPath)}.`); |
There was a problem hiding this comment.
The production error message hard-codes "is not writable", but the failure from readOrWritePersistedSecret(secretPath) could also be a read failure (e.g., EACCES reading an existing file) or a rename/readback failure. Consider rewording to something accurate like "could not be read or created" (and optionally include e.code) so operators aren’t misled about the actual fault.
| log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} is not writable (${e.message}).`); | |
| log.error(`Set TOKEN_SECRET explicitly, or grant write access to ${path.dirname(secretPath)}.`); | |
| const codeSuffix = e?.code ? ` [${e.code}]` : ''; | |
| log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} could not be read or created${codeSuffix} (${e.message}).`); | |
| log.error(`Set TOKEN_SECRET explicitly, or grant the necessary access to ${path.dirname(secretPath)}.`); |
| // Simulates the lose-a-race case: another process created the file | ||
| // between our read and our write. Exclusive-create fails EEXIST and | ||
| // we fall back to reading / (if empty) writing without wx. |
There was a problem hiding this comment.
This test comment mentions an exclusive-create ("wx") / EEXIST race, but readOrWritePersistedSecret() currently uses a temp file + rename strategy rather than exclusive creation. Updating the comment to describe the actual concurrency behavior would make the test easier to maintain.
| // Simulates the lose-a-race case: another process created the file | |
| // between our read and our write. Exclusive-create fails EEXIST and | |
| // we fall back to reading / (if empty) writing without wx. | |
| // Simulates a concurrent or interrupted persistence case: the target | |
| // secret file is already present by the time we inspect it, but it | |
| // contains no usable secret yet. We should repair it by writing one. |
- Fast-path comment no longer claims "without … tightening the containing directory"; chmod of dir + file is still there, just best-effort, which is what lets the read-only-FS path work. - Production error wording generalised from "is not writable" to "could not be read or created" since the failure could also be a read or rename/readback error; error code is appended when present. - Empty-file-recovery test comment references the current tmp+rename strategy rather than the older wx/EEXIST one.
Fixes #280.
Summary
Previously a production boot without a `TOKEN_SECRET` env var hard-exited. A beginner starting JSS under pm2 with `NODE_ENV=production` (a natural default) was greeted by a SECURITY ERROR that pointed at a `node -e …` command they might not even have in their shell path — see serverproject-dev/solidweb.app#1 for exactly that scenario.
Change
Resolution order is now:
Operators who set `TOKEN_SECRET` explicitly see no behaviour change.
Why `~/.jss` and not `/.jss`
`urlToPath()` does not filter dotfiles; the traversal guard only blocks `..`. A `GET /.jss/token.secret` against a server rooted at `` would happily resolve to the secret file. Staying outside `dataRoot` sidesteps that entirely.
`os.homedir()` + `path.join` works cross-platform — same family as `
/.npm`, `/.gh`, `/.docker`, `/.aws`. File mode 0o600 is honoured on POSIX and silently ignored on Windows (which uses ACLs) — passing it is harmless on Windows and correct on Linux/macOS.Structure
Pulled the resolution logic into `src/auth/token-secret.js` so it can be unit-tested. Importing `token.js` triggers `solid-oidc.js` / `nostr.js` / `webid-tls.js`, which do module-level work that keeps the `node:test` event loop alive — so tests that imported the full module hung. The new file has only stdlib imports.
Test plan