Skip to content

Auto-generate and persist TOKEN_SECRET on first run (#280) - #281

Merged
melvincarvalho merged 4 commits into
JavaScriptSolidServer:gh-pagesfrom
melvincarvalho:issue-280-auto-persist-token-secret
Apr 21, 2026
Merged

Auto-generate and persist TOKEN_SECRET on first run (#280)#281
melvincarvalho merged 4 commits into
JavaScriptSolidServer:gh-pagesfrom
melvincarvalho:issue-280-auto-persist-token-secret

Conversation

@melvincarvalho

Copy link
Copy Markdown
Contributor

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:

  1. `TOKEN_SECRET` env var — unchanged.
  2. `~/.jss/token.secret` — auto-generated on first run, dir `chmod 700`, file `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 with a warning (matches prior dev behaviour).

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

  • New `test/token-secret.test.js` — 10 cases: env-wins, read-write cycle, same-secret-on-reread, POSIX permissions, non-ENOENT propagation, production-exit-on-failure, dev-ephemeral-on-failure. Uses a planted regular file to provoke `ENOTDIR` (portable across OSes — `/proc/` tricks hang on Linux).
  • Existing suite: `npm test` → 374/374 pass (was 364 before adding 10 new cases).
  • Manual: `NODE_ENV=production` boot with no `TOKEN_SECRET` → server comes up, `~/.jss/token.secret` written with 0600.
  • Manual: second boot → same secret read back, same tokens still valid.

…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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js to resolve TOKEN_SECRET via env → persisted secret file → (prod exit | dev ephemeral fallback).
  • Update src/auth/token.js to use the new resolver at module load.
  • Add test/token-secret.test.js covering 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.

Comment thread src/auth/token-secret.js Outdated
Comment on lines +28 to +30
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).

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment thread src/auth/token-secret.js Outdated
Comment on lines +28 to +31
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 });

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/auth/token-secret.js Outdated
} 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/.');

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)}.`);

Copilot uses AI. Check for mistakes.
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/auth/token-secret.js Outdated
Comment on lines +37 to +42
// 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);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/auth/token-secret.js
Comment on lines +55 to +75
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);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/auth/token-secret.js Outdated
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`

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return undefined; // for tests that stub `exit`
throw new Error(`Failed to resolve TOKEN_SECRET in production: ${e.message}`);

Copilot uses AI. Check for mistakes.
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).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/auth/token-secret.js Outdated
Comment on lines +46 to +48
// 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 {

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/auth/token-secret.js Outdated
Comment on lines +108 to +109
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)}.`);

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)}.`);

Copilot uses AI. Check for mistakes.
Comment thread test/token-secret.test.js Outdated
Comment on lines +61 to +63
// 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.

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
- 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.
@melvincarvalho
melvincarvalho merged commit 3de322a into JavaScriptSolidServer:gh-pages Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-generate and persist TOKEN_SECRET on first run

2 participants