diff --git a/README.md b/README.md index 83e1fa6..f6f2f50 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ jspod --multiuser ``` Options: -p, --port Port to listen on (default: 5444) - -h, --host
Host to bind to (default: 0.0.0.0) + -h, --host
Host to bind to (default: 127.0.0.1) -r, --root Data directory (default: ./pod-data) --multiuser Enable multi-user mode --no-auth Disable authentication @@ -91,8 +91,9 @@ Options: ### Environment Variables ```bash -# Set JWT secret (recommended for production) -export TOKEN_SECRET="your-secret-key-here" +# JWT secret — auto-generated and persisted at /.token-secret on +# first run. Override here only for operator-managed deployments. +export TOKEN_SECRET="$(openssl rand -base64 32)" # Set environment export NODE_ENV="production" @@ -105,7 +106,7 @@ jspod **⚠️ Important**: Before deploying to production: -1. **Set TOKEN_SECRET** +1. **TOKEN_SECRET** is auto-generated on first run and persisted at `/.token-secret` (mode 0600). For operator-managed deployments — secret rotation, distributed setups, secret managers — set it explicitly via env: ```bash export TOKEN_SECRET="$(openssl rand -base64 32)" ``` @@ -198,10 +199,12 @@ Under the hood, jspod runs JavaScriptSolidServer with these options: ```javascript { port: 5444, // Memorable, low collision with common dev servers - host: '0.0.0.0', // Accept connections from anywhere + host: '127.0.0.1', // Localhost-only by default (rung-1 credentials) root: './pod-data', // Local data directory multiuser: false, // Single pod per server - TOKEN_SECRET: (auto) // JWT secret (auto-generated, change for production) + TOKEN_SECRET: (per-pod) // Auto-generated on first run and persisted at + // /.token-secret (mode 0600). Override via + // the TOKEN_SECRET env var for operator control. } ``` @@ -216,15 +219,39 @@ npx jspod > Running over SSH, in CI, or in a non-interactive terminal? jspod skips auto-open and prints the URL instead. You can also pass `--no-open` to disable it explicitly. -**Step 3**: Register with a passkey -- Click "Register" or "Sign Up" -- Use your device's biometric auth (fingerprint, Face ID, etc.) -- Your WebID will be created automatically +**Step 3**: Sign in (rung 1 of the auth ladder) -**Step 4**: Start using your pod! -- Upload files, create resources -- Use Solid apps to connect to your pod +The first time you start jspod, an IDP account is seeded with deliberately weak default credentials: + +| Field | Value | +| -------- | ----- | +| Username | `me` | +| Password | `me` | + +Click **Sign in** on the welcome page, then point a Solid app (like [Pilot](https://solid-apps.github.io/pilot/)) at `http://localhost:5444` and sign in with `me` / `me`. + +> **Why are the defaults so weak?** jspod ships you onto the first rung of the auth ladder in under a minute, then guides you up. Rung 1 is **only safe on localhost** — jspod binds to `127.0.0.1` by default for exactly this reason. Once you're in, change the password (rung 2) or add a passkey (rung 3) from your pod's account settings. See [issue #6](https://github.com/JavaScriptSolidServer/jspod/issues/6) for the ladder rationale. + +**Step 4**: Climb the ladder + +| Rung | Auth | How | +| ---- | --------------------- | -------------------------------------------- | +| 0 | None | `npx jspod --no-auth` (demos / dev only) | +| 1 | `me` / `me` | **Default.** Localhost-only. | +| 2 | Your password | Change it from your pod's account settings | +| 3 | Passkey | Add a passkey from account settings | +| 4 | Hardware key / MFA | Power-user setup | + +Override the default password without going through the UI: + +```bash +JSS_SINGLE_USER_PASSWORD='your-password' npx jspod +``` + +**Step 5**: Start using your pod +- Upload files, create resources from a Solid app - Your data stays on your server +- Connect more apps — they all authenticate against your IDP **Troubleshooting**: - **Port in use?** Run `jspod --port 5445` diff --git a/index.js b/index.js index 8368e44..97f8982 100755 --- a/index.js +++ b/index.js @@ -9,7 +9,8 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, join, delimiter } from 'path'; import chalk from 'chalk'; -import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'fs'; +import { randomBytes } from 'crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); @@ -17,6 +18,10 @@ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); // Build a browser-friendly URL from a host/port pair. Normalizes wildcard // addresses (0.0.0.0, ::) to localhost and brackets IPv6 literals so the // result is always a valid URL the user (and the browser) can open. +// IPv6 zone identifiers (e.g. `fe80::1%lo0`) are rejected at CLI parse +// time — the WHATWG URL spec doesn't support them, so any URL we built +// with one would be unparseable by Node and by the browser regardless +// of `%` encoding. function formatUrl(host, port) { if (host === '0.0.0.0' || host === '::' || host === '*') { return `http://localhost:${port}`; @@ -31,22 +36,79 @@ function formatUrl(host, port) { const args = process.argv.slice(2); const options = { port: 5444, - host: '0.0.0.0', + host: '127.0.0.1', root: './pod-data', multiuser: false, auth: true, open: true }; +// Auth-ladder rung-1 credentials. See issue #6: jspod ships a deliberately +// weak default sign-in so the new user is on a working pod within seconds, +// with a clearly-marked path to climb (change password / add a passkey). +// Safe because the default host is localhost-only (127.0.0.1). +// Username is fixed by JSS for root pods (server.js:970). Password defaults +// to 'me' but can be overridden via JSS_SINGLE_USER_PASSWORD so the env +// override documented in the README actually takes effect (and the banner +// shows the effective password, not a stale default). +const RUNG_1_USERNAME = 'me'; +const RUNG_1_PASSWORD = process.env.JSS_SINGLE_USER_PASSWORD || 'me'; +const RUNG_1_PASSWORD_FROM_ENV = !!process.env.JSS_SINGLE_USER_PASSWORD; + +// Require a value after a value-taking flag. Without this guard, a stray +// `jspod --host` (no value) reads `undefined` from args[++i] and the next +// .replace() call throws a cryptic TypeError. We also reject values that +// look like another option (`-`-prefixed) — otherwise `jspod --host +// --no-auth` would silently consume `--no-auth` as the host value, drop +// the intended flag, and bind the server to a literal string '--no-auth'. +function requireValue(flag, value) { + if (value === undefined) { + console.error(chalk.red(`✗ Missing value for ${flag}`)); + console.error(chalk.dim('Use --help for usage information')); + process.exit(1); + } + if (value.startsWith('-')) { + console.error(chalk.red(`✗ Missing value for ${flag}`)); + console.error(chalk.dim(` Got: ${value} (looks like another option, not a value)`)); + console.error(chalk.dim('Use --help for usage information')); + process.exit(1); + } + return value; +} + for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--port' || arg === '-p') { - options.port = parseInt(args[++i], 10); + const raw = requireValue(arg, args[++i]); + const parsed = parseInt(raw, 10); + // Reject non-numeric / out-of-range / privileged ports. parseInt('abc') + // returns NaN, which would silently propagate to JSS as `--port NaN` + // and produce a confusing crash deep in the server. + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw.trim()) { + console.error(chalk.red(`✗ Invalid port: ${raw}`)); + console.error(chalk.dim('Port must be an integer in the range 1-65535.')); + process.exit(1); + } + options.port = parsed; } else if (arg === '--host' || arg === '-h') { - options.host = args[++i]; + // Strip optional brackets from IPv6 literals so a user-friendly + // `--host [::1]` paste-in stays canonical. formatUrl re-adds the + // brackets where they belong in URLs; the raw host going to JSS + // and to comparisons remains the unbracketed literal. + const rawHost = requireValue(arg, args[++i]).replace(/^\[|\]$/g, ''); + // Reject IPv6 zone identifiers — WHATWG URL spec doesn't support + // them, so any URL we built (banner, browser auto-open, readiness + // probe) would be unparseable. Better to fail fast with a clear + // message than to ship a broken auto-open silently. + if (rawHost.includes('%')) { + console.error(chalk.red(`✗ IPv6 zone identifiers are not supported: ${rawHost}`)); + console.error(chalk.dim('Bind to a non-zoned address (e.g. ::1, 127.0.0.1, or your LAN IP) instead.')); + process.exit(1); + } + options.host = rawHost; } else if (arg === '--root' || arg === '-r') { - options.root = args[++i]; + options.root = requireValue(arg, args[++i]); } else if (arg === '--multiuser') { options.multiuser = true; } else if (arg === '--no-auth') { @@ -66,7 +128,7 @@ for (let i = 0; i < args.length; i++) { console.log(chalk.yellow(' jspod') + chalk.dim(' [options]\n')); console.log(chalk.white('Options:')); console.log(chalk.green(' -p, --port ') + chalk.yellow('') + chalk.dim(' Port to listen on (default: 5444)')); - console.log(chalk.green(' -h, --host ') + chalk.yellow('
') + chalk.dim(' Host to bind to (default: 0.0.0.0)')); + console.log(chalk.green(' -h, --host ') + chalk.yellow('
') + chalk.dim(' Host to bind to (default: 127.0.0.1)')); console.log(chalk.green(' -r, --root ') + chalk.yellow('') + chalk.dim(' Data directory (default: ./pod-data)')); console.log(chalk.green(' --multiuser') + chalk.dim(' Enable multi-user mode')); console.log(chalk.green(' --no-auth') + chalk.dim(' Disable authentication')); @@ -99,6 +161,61 @@ if (!existsSync(options.root)) { mkdirSync(options.root, { recursive: true }); } +// Resolve the JWT signing secret. Priority: +// 1. TOKEN_SECRET env var (operator-controlled) +// 2. Persisted random secret at /.token-secret (generated on +// first run, mode 0600). Same data dir always produces the same +// effective secret across restarts — sessions and refresh tokens +// survive process bounces. +// Generating per-data-dir avoids the previous footgun of a hardcoded +// fallback string that anyone could use to forge JWTs against a +// non-loopback deployment. +function resolveTokenSecret(rootDir) { + if (process.env.TOKEN_SECRET) return process.env.TOKEN_SECRET; + const secretFile = join(rootDir, '.token-secret'); + if (existsSync(secretFile)) { + // Tighten perms on every read: writeFileSync's `mode` option only + // applies to *creation*, so a regenerated file (overwritten in + // place) or a manually-touched file may have inherited broader + // permissions. Stat-then-chmod also warns the operator if the + // file was previously group/world-readable. + ensureMode0600(secretFile); + const loaded = readFileSync(secretFile, 'utf8').trim(); + // Guard against a truncated / empty / accidentally-overwritten + // secret file. A short-or-empty secret would silently weaken JWT + // signing — regenerate and warn rather than ship the bad value. + if (loaded.length >= 32) return loaded; + console.warn(chalk.yellow( + `⚠ ${secretFile} is empty or too short (${loaded.length} chars); regenerating.` + )); + } + const secret = randomBytes(48).toString('base64'); + writeFileSync(secretFile, secret, { mode: 0o600 }); + // Explicit chmod covers the overwrite case (mode option in + // writeFileSync is ignored when the file already exists). + ensureMode0600(secretFile); + return secret; +} + +function ensureMode0600(path) { + try { + const mode = statSync(path).mode & 0o777; + if (mode !== 0o600) { + // Surface the previous mode so operators can investigate how the + // file became group/world-readable (or just learn that jspod is + // tightening it for them). + console.warn(chalk.yellow( + `⚠ Tightening permissions on ${path} (was ${mode.toString(8).padStart(3, '0')}, now 600)` + )); + chmodSync(path, 0o600); + } + } catch { + // chmod is a no-op on Windows and may fail on exotic filesystems. + // Don't crash startup over it; the secret is still in use. + } +} +const tokenSecret = resolveTokenSecret(options.root); + // Display startup banner console.log(chalk.cyan(` ╔═══════════════════════════════════════════════════════════════════╗ @@ -125,6 +242,55 @@ console.log(chalk.cyan(' ├─ ') + chalk.white('Host: ') + chalk.yellow console.log(chalk.cyan(' ├─ ') + chalk.white('Pod Root: ') + chalk.yellow(options.root)); console.log(chalk.cyan(' └─ ') + chalk.white('Mode: ') + (options.multiuser ? chalk.yellow('Multi-user') : chalk.yellow('Single-user'))); +if (options.auth && !options.multiuser) { + const rungLabel = RUNG_1_PASSWORD_FROM_ENV + ? 'Sign In (password from JSS_SINGLE_USER_PASSWORD):' + : 'Sign In (rung 1 of the auth ladder):'; + console.log('\n' + chalk.bold.white(`🔑 ${rungLabel}\n`)); + console.log(chalk.cyan(' ├─ ') + chalk.white('Username: ') + chalk.bold.green(RUNG_1_USERNAME)); + // Only print the literal password when it's the rung-1 default. If + // the user set a real password via env, echoing it to stdout would + // leak into terminal scrollback, shell history capture, CI logs, and + // shared sessions. They already know the value they set; the banner + // just confirms it was picked up. + if (RUNG_1_PASSWORD_FROM_ENV) { + console.log(chalk.cyan(' ├─ ') + chalk.white('Password: ') + chalk.dim('(hidden — set via JSS_SINGLE_USER_PASSWORD)')); + } else { + console.log(chalk.cyan(' ├─ ') + chalk.white('Password: ') + chalk.bold.green(RUNG_1_PASSWORD)); + } + console.log(chalk.cyan(' └─ ') + chalk.dim('Climb: change the password or add a passkey from account settings')); + + // Loud warning if the rung-1 known credentials are reachable beyond + // the local machine. See issue #6 ("auth ladder"): rung 1 is only + // safe when the host is loopback-only. Any other bind exposes the + // well-known me/me credentials to the LAN (or worse). + // Loopback covers the full 127.0.0.0/8 IPv4 range plus IPv6 ::1. + // (Bracketed `[::1]` input is stripped to `::1` at CLI parse time + // — see options.host parsing — so it matches here without a + // bracketed branch.) + const isLoopback = + options.host === 'localhost' || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(options.host) || + options.host === '::1'; + if (!isLoopback) { + if (RUNG_1_PASSWORD_FROM_ENV) { + // Custom password from env. Still worth warning the user that + // their sign-in is now reachable from anywhere this host + // answers, but no longer accurate to call the credentials + // "well-known." + console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( + `--host ${options.host} exposes single-user sign-in beyond localhost.` + )); + console.log(chalk.dim(' Make sure your JSS_SINGLE_USER_PASSWORD is strong, and use HTTPS in production.')); + } else { + console.log('\n' + chalk.bold.red('⚠ Warning: ') + chalk.yellow( + `--host ${options.host} exposes the well-known me/me credentials beyond localhost.` + )); + console.log(chalk.dim(' Set JSS_SINGLE_USER_PASSWORD=... before running, or bind to 127.0.0.1.')); + } + } +} + console.log('\n' + chalk.bold.white('✨ Features:\n')); console.log(chalk.cyan(' ├─ ') + chalk.green('Solid Protocol ') + chalk.bold.green('✓')); console.log(chalk.cyan(' ├─ ') + chalk.green('WebID Auth ') + (options.auth ? chalk.bold.green('✓') : chalk.dim('✗'))); @@ -150,8 +316,34 @@ const jssArgs = [ '--conneg' ]; -if (!options.multiuser) { - jssArgs.push('--no-multiuser'); +if (options.multiuser) { + // Multi-user mode is an explicit opt-out from jspod's single-user + // positioning (#3). The IDP stays available so users can register. + if (options.auth) jssArgs.push('--idp'); +} else { + // Default: single-user personal pod with rung-1 credentials seeded. + // The pod, IDP, and known credentials are created on first start; + // every subsequent start is a no-op (JSS is idempotent on the seed). + jssArgs.push('--no-multiuser', '--single-user'); + if (options.auth) { + jssArgs.push('--idp'); + // Pass the rung-1 placeholder on argv (it has no secrecy property + // — anyone reading the docs already knows the literal 'me'). For + // an env-supplied password, *don't* re-expose it on argv where + // `ps`, service-manager logs, and other local users can read it. + // JSS reads JSS_SINGLE_USER_PASSWORD from env directly when no + // CLI flag is given, and we forward process.env to the child. + if (!RUNG_1_PASSWORD_FROM_ENV) { + jssArgs.push('--single-user-password', RUNG_1_PASSWORD); + } + } +} + +if (!options.auth) { + // JSS's `--public` is the real no-auth switch: skip WAC, open + // read/write. Without it, `--no-auth` would only mean "no IDP" + // — the pod would still be ACL-gated and unreachable. + jssArgs.push('--public'); } // Start JSS with enhanced PATH to find the binary @@ -160,7 +352,7 @@ const jss = spawn('jss', jssArgs, { env: { ...process.env, PATH: `${join(__dirname, 'node_modules', '.bin')}${delimiter}${process.env.PATH}`, - TOKEN_SECRET: process.env.TOKEN_SECRET || 'jspod-default-secret-change-in-production', + TOKEN_SECRET: tokenSecret, NODE_ENV: process.env.NODE_ENV || 'development' } }); diff --git a/package.json b/package.json index 96aeebc..f0514ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jspod", - "version": "0.0.9", + "version": "0.0.10", "description": "JavaScript Solid Pod - Just works, batteries included", "type": "module", "main": "index.js",