-
Notifications
You must be signed in to change notification settings - Fork 9
Seed IDP account on single-user --idp startup (#323) #324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7012f61
2b7e675
5f1d7ea
052bf0a
950bc0b
f02febb
3892f0d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,6 +75,11 @@ export const defaults = { | |
| // Single-user mode (personal pod server) | ||
| singleUser: false, | ||
| singleUserName: 'me', | ||
| // Initial IDP password seeded on first single-user pod creation. If | ||
| // unset and --idp is enabled, the server prompts on a TTY or logs a | ||
| // warning and continues startup on non-TTY (so the pod is created but | ||
| // is not yet loggable until a password is set). | ||
| singleUserPassword: null, | ||
|
|
||
| // WebID-TLS client certificate authentication | ||
| webidTls: false, | ||
|
|
@@ -154,6 +159,7 @@ const envMap = { | |
| JSS_INVITE_ONLY: 'inviteOnly', | ||
| JSS_SINGLE_USER: 'singleUser', | ||
| JSS_SINGLE_USER_NAME: 'singleUserName', | ||
| JSS_SINGLE_USER_PASSWORD: 'singleUserPassword', | ||
| JSS_WEBID_TLS: 'webidTls', | ||
| JSS_DEFAULT_QUOTA: 'defaultQuota', | ||
| JSS_PUBLIC: 'public', | ||
|
|
@@ -184,15 +190,53 @@ export function parseSize(str) { | |
| return Math.floor(num * (multipliers[unit] || 1)); | ||
| } | ||
|
|
||
| /** | ||
| * Config keys whose values are genuinely boolean. Only these get the | ||
| * "true"/"false" string coercion below — otherwise a user-supplied | ||
| * password (or any other string-valued option) like "true"/"false" | ||
| * would silently turn into a boolean and break downstream code (e.g. | ||
| * bcrypt hashing). | ||
| */ | ||
| const BOOLEAN_KEYS = new Set([ | ||
| 'ssl', | ||
| 'conneg', | ||
| 'subdomains', | ||
| 'mashlib', | ||
| 'mashlibCdn', | ||
| 'git', | ||
| 'nostr', | ||
| 'webrtc', | ||
| 'terminal', | ||
| 'tunnel', | ||
| 'activitypub', | ||
| 'inviteOnly', | ||
| 'multiuser', | ||
| 'singleUser', | ||
| 'webidTls', | ||
| 'public', | ||
| 'readOnly', | ||
| 'liveReload', | ||
| 'pay', | ||
| 'mongo', | ||
| 'idp', | ||
| 'notifications', | ||
| 'logger', | ||
| 'quiet' | ||
| ]); | ||
|
Comment on lines
+200
to
+225
|
||
|
|
||
| /** | ||
| * Parse a value from environment variable string | ||
| */ | ||
| function parseEnvValue(value, key) { | ||
| if (value === undefined) return undefined; | ||
|
|
||
| // Boolean values | ||
| if (value.toLowerCase() === 'true') return true; | ||
| if (value.toLowerCase() === 'false') return false; | ||
| // Boolean values — only for known boolean keys; everything else | ||
| // stays a string so passwords / tokens / arbitrary text aren't | ||
| // silently coerced to booleans. | ||
| if (BOOLEAN_KEYS.has(key)) { | ||
| if (value.toLowerCase() === 'true') return true; | ||
| if (value.toLowerCase() === 'false') return false; | ||
| } | ||
|
|
||
| // Numeric values for known numeric keys | ||
| if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) { | ||
|
|
@@ -311,6 +355,10 @@ export async function saveConfig(config, configFile) { | |
| // Remove derived/runtime values | ||
| delete toSave.ssl; | ||
| delete toSave.logger; | ||
| // Never persist secrets to a static config file. The password is | ||
| // expected to come from --single-user-password or | ||
| // JSS_SINGLE_USER_PASSWORD at runtime, not be written into .jss/config. | ||
| delete toSave.singleUserPassword; | ||
|
|
||
| await fs.ensureDir(path.dirname(configFile)); | ||
| await fs.writeFile(configFile, JSON.stringify(toSave, null, 2)); | ||
|
|
@@ -327,6 +375,24 @@ export function printConfig(config) { | |
| console.log(` Root: ${path.resolve(config.root)}`); | ||
| console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`); | ||
| console.log(` Multi-user: ${config.multiuser}`); | ||
| if (config.singleUser) { | ||
| let details = `${config.singleUserName}`; | ||
| // Password seeding only runs when --idp is on AND the pod isn't the | ||
| // root-level case ('/'). Reflect both gates in the printed line so | ||
| // operators don't see a misleading "missing — login disabled" when | ||
| // login isn't governed by an IDP password at all. | ||
| if (config.idp) { | ||
| if (config.singleUserName === '/' || !config.singleUserName) { | ||
| details += ' (root pod; password not seeded)'; | ||
| } else { | ||
| const pwSource = config.singleUserPassword | ||
| ? 'provided' | ||
| : (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled'); | ||
| details += ` (password: ${pwSource})`; | ||
| } | ||
| } | ||
| console.log(` Single-user: ${details}`); | ||
| } | ||
| console.log(` Conneg: ${config.conneg}`); | ||
| console.log(` Notifications: ${config.notifications}`); | ||
| console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,6 +94,7 @@ export function createServer(options = {}) { | |
| // Single-user mode - creates pod on startup, disables registration | ||
| const singleUser = options.singleUser ?? false; | ||
| const singleUserName = options.singleUserName ?? 'me'; | ||
| const singleUserPassword = options.singleUserPassword ?? null; | ||
| // Default storage quota per pod (50MB default, 0 = unlimited) | ||
| const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024; | ||
| // WebID-TLS client certificate authentication is OFF by default | ||
|
|
@@ -561,15 +562,21 @@ export function createServer(options = {}) { | |
| const isRootPod = !singleUserName || singleUserName === '/'; | ||
| const podPath = isRootPod ? '/' : `/${singleUserName}/`; | ||
| const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`; | ||
| const webId = `${podUri}profile/card.jsonld#me`; | ||
| const displayName = isRootPod ? 'me' : singleUserName; | ||
|
|
||
| // Check if pod already exists. Accept either the new `card.jsonld` | ||
| // or legacy extensionless `card` layout so we don't re-seed a pod | ||
| // that was created by an older JSS version. | ||
| const profileExists = | ||
| await storage.exists(`${podPath}profile/card.jsonld`) || | ||
| await storage.exists(`${podPath}profile/card`); | ||
| // that was created by an older JSS version. Compute the effective | ||
| // WebID against whichever profile file actually resolves — a | ||
| // legacy pod must keep its `/profile/card#me` WebID, otherwise the | ||
| // seeded IDP account would point at a non-existent document. | ||
| const hasJsonLd = await storage.exists(`${podPath}profile/card.jsonld`); | ||
| const hasLegacy = !hasJsonLd && await storage.exists(`${podPath}profile/card`); | ||
| const profileFile = hasJsonLd ? 'profile/card.jsonld' | ||
| : hasLegacy ? 'profile/card' | ||
| : 'profile/card.jsonld'; // fresh pod default | ||
| const webId = `${podUri}${profileFile}#me`; | ||
| const profileExists = hasJsonLd || hasLegacy; | ||
|
|
||
| if (!profileExists) { | ||
| fastify.log.info(`Creating single-user pod at ${podUri}...`); | ||
|
|
@@ -583,6 +590,123 @@ export function createServer(options = {}) { | |
| } | ||
| fastify.log.info(`Single-user pod created at ${podUri}`); | ||
| } | ||
|
|
||
| // Seed an IDP account so the operator can actually log in. Without | ||
| // this, single-user + --idp produces a pod but no credential, and | ||
| // registration is intentionally disabled in single-user mode — so | ||
| // the pod is unloggable until a password is set externally (#323). | ||
| if (idpEnabled && !isRootPod) { | ||
| await seedSingleUserIdpAccount({ | ||
| fastify, | ||
| username: singleUserName, | ||
| webId, | ||
| podName: singleUserName, | ||
| providedPassword: singleUserPassword | ||
| }); | ||
|
Comment on lines
+598
to
+605
|
||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Seed an IDP account for the single-user pod owner if one doesn't | ||
| * already exist. Password sources, in priority order: | ||
| * 1. `--single-user-password` / `JSS_SINGLE_USER_PASSWORD` | ||
| * 2. interactive prompt (TTY only) | ||
| * 3. error — server stays up but logs that login won't work yet | ||
| */ | ||
| async function seedSingleUserIdpAccount({ fastify, username, webId, podName, providedPassword }) { | ||
| const { findByUsername, createAccount } = await import('./idp/accounts.js'); | ||
| const existing = await findByUsername(username); | ||
| if (existing) return; // already seeded — idempotent | ||
|
|
||
| // Treat anything that isn't a non-empty string as "not provided" so | ||
| // a misconfigured env coercion or stray boolean can't reach bcrypt. | ||
| let password = (typeof providedPassword === 'string' && providedPassword.length > 0) | ||
| ? providedPassword | ||
| : null; | ||
|
|
||
| if (!password) { | ||
| if (process.stdin.isTTY && process.stdout.isTTY) { | ||
| try { | ||
| password = await promptPasswordOnce(`[jss] Set initial IDP password for "${username}": `); | ||
| } catch (err) { | ||
| fastify.log.warn({ err }, `Password prompt failed for "${username}"`); | ||
| return; | ||
| } | ||
| } else { | ||
| fastify.log.warn( | ||
| `--single-user --idp: no password provided. Set --single-user-password or ` + | ||
| `JSS_SINGLE_USER_PASSWORD before starting (or run on a TTY to be prompted). ` + | ||
| `Login is currently not possible for "${username}".` | ||
| ); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| if (typeof password !== 'string' || password.length === 0) { | ||
| fastify.log.warn(`Empty password — skipping IDP account creation for "${username}".`); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| await createAccount({ username, password, webId, podName }); | ||
| fastify.log.info(`IDP account seeded for single-user "${username}".`); | ||
| } catch (err) { | ||
| fastify.log.error({ err }, `Failed to seed IDP account for "${username}"`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Read a password from stdin without echoing it. Uses the public | ||
| * `emitKeypressEvents` + raw-mode keypress API rather than overriding | ||
| * the underscored `_writeToOutput` on a `readline.Interface`, which is | ||
| * a private/unstable hook. | ||
| */ | ||
| async function promptPasswordOnce(prompt) { | ||
| const { emitKeypressEvents } = await import('node:readline'); | ||
| const stdin = process.stdin; | ||
| const stdout = process.stdout; | ||
| if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') { | ||
| throw new Error('Interactive password prompt requires a TTY'); | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| let password = ''; | ||
| const wasRaw = stdin.isRaw === true; | ||
| const onKeypress = (str, key = {}) => { | ||
| if (key.ctrl && key.name === 'c') { | ||
| cleanup(); | ||
| reject(new Error('Password prompt cancelled')); | ||
| return; | ||
| } | ||
| if (key.name === 'return' || key.name === 'enter') { | ||
| cleanup(); | ||
| resolve(password); | ||
| return; | ||
| } | ||
| if (key.name === 'backspace' || key.name === 'delete') { | ||
| password = password.slice(0, -1); | ||
| return; | ||
| } | ||
| // Only accept printable input — \P{C} excludes control codes, | ||
| // so escape sequences from arrow keys, function keys, etc. don't | ||
| // sneak invisible bytes into the password buffer. | ||
| if (!key.ctrl && !key.meta && | ||
| typeof str === 'string' && str.length > 0 && | ||
| /^\P{C}+$/u.test(str)) { | ||
| password += str; | ||
| } | ||
| }; | ||
| const cleanup = () => { | ||
| stdin.removeListener('keypress', onKeypress); | ||
| if (!wasRaw) stdin.setRawMode(false); | ||
| stdout.write('\n'); | ||
| stdin.pause(); | ||
| }; | ||
| emitKeypressEvents(stdin); | ||
| stdout.write(prompt); | ||
| if (!wasRaw) stdin.setRawMode(true); | ||
| stdin.resume(); | ||
| stdin.on('keypress', onKeypress); | ||
| }); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| /** | ||
| * Config / env-var parsing tests. | ||
| * | ||
| * Regression coverage for the env-coercion fix in #323: only known | ||
| * boolean keys may have their string values coerced to booleans. | ||
| * Otherwise an env var like JSS_SINGLE_USER_PASSWORD="true" would silently | ||
| * become a real boolean and break downstream code (bcrypt, etc.). | ||
| */ | ||
|
|
||
| import { describe, it, before, after } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import { loadConfig } from '../src/config.js'; | ||
|
|
||
| describe('config — env var boolean coercion', () => { | ||
| // Save/restore the env vars we touch so this test is hermetic. | ||
| const KEYS = ['JSS_SINGLE_USER_PASSWORD', 'JSS_IDP', 'JSS_BASE_DOMAIN', 'JSS_MULTIUSER']; | ||
| const original = {}; | ||
| before(() => { for (const k of KEYS) original[k] = process.env[k]; }); | ||
| after(() => { | ||
| for (const k of KEYS) { | ||
| if (original[k] === undefined) delete process.env[k]; | ||
| else process.env[k] = original[k]; | ||
| } | ||
| }); | ||
|
|
||
| it('preserves string-valued env vars when their value is "true"', async () => { | ||
| process.env.JSS_SINGLE_USER_PASSWORD = 'true'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.singleUserPassword, 'true', | ||
| 'password env var must remain a string, not be coerced to boolean true'); | ||
| }); | ||
|
|
||
| it('preserves string-valued env vars when their value is "false"', async () => { | ||
| process.env.JSS_SINGLE_USER_PASSWORD = 'false'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.singleUserPassword, 'false'); | ||
| }); | ||
|
|
||
| it('preserves string-valued env vars when set to other strings', async () => { | ||
| process.env.JSS_BASE_DOMAIN = 'example.com'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.baseDomain, 'example.com'); | ||
| }); | ||
|
|
||
| it('still coerces known boolean keys', async () => { | ||
| process.env.JSS_IDP = 'true'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.idp, true, 'idp env var should be coerced to boolean'); | ||
| }); | ||
|
|
||
| it('still coerces known boolean keys when "false"', async () => { | ||
| process.env.JSS_IDP = 'false'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.idp, false); | ||
| }); | ||
|
|
||
| it('coerces JSS_MULTIUSER to a boolean (regression for missed entry)', async () => { | ||
| process.env.JSS_MULTIUSER = 'false'; | ||
| const cfg = await loadConfig({}, null); | ||
| assert.strictEqual(cfg.multiuser, false, | ||
| 'multiuser must coerce to boolean false, not the string "false" (truthy)'); | ||
| process.env.JSS_MULTIUSER = 'true'; | ||
| const cfg2 = await loadConfig({}, null); | ||
| assert.strictEqual(cfg2.multiuser, true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSS_SINGLE_USER_PASSWORDis parsed viaparseEnvValue(), which currently coerces the literal strings "true"/"false" into booleans for all env keys. That means a user cannot set their password to "true" or "false" via env var (and a boolean may flow into bcrypt hashing). Consider changing env parsing to only coerce booleans for a known allowlist of boolean keys, or special-casesingleUserPasswordto always remain a string.