import { randomBytes } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { JoseKey } from "@atproto/oauth-client-node"; const args = parseArgs(process.argv.slice(2)); const origin = new URL(required(args.origin, "--origin")); if (origin.protocol !== "https:" || origin.pathname !== "/" || origin.search || origin.hash || origin.username || origin.password) { throw new Error("--origin must be one bare HTTPS origin, for example https://thought.stream"); } const did = required(args.did, "--did"); if (!/^did:(plc|web):/.test(did)) throw new Error("--did must be an ATProto did:plc or did:web identity"); const handle = required(args.handle, "--handle"); if (!/^[a-z0-9][a-z0-9.-]+$/i.test(handle)) throw new Error("--handle is invalid"); const credentialsDirectory = process.env.THOUGHTSTREAM_CREDENTIALS_DIR ?? path.join(os.homedir(), ".config", "thoughtstream", "credentials"); if (process.env.THOUGHTSTREAM_OAUTH_STORE_DIR) { throw new Error("THOUGHTSTREAM_OAUTH_STORE_DIR is unsupported; the systemd sandbox permits only ~/.local/share/thoughtstream-inspector-auth"); } const storeDirectory = path.join(os.homedir(), ".local", "share", "thoughtstream-inspector-auth"); const destination = path.join(credentialsDirectory, "inspector-oauth.env"); await refuseSymlink(credentialsDirectory); await fs.mkdir(credentialsDirectory, { recursive: true, mode: 0o700 }); await fs.chmod(credentialsDirectory, 0o700); await refuseSymlink(storeDirectory); await fs.mkdir(storeDirectory, { recursive: true, mode: 0o700 }); await fs.chmod(storeDirectory, 0o700); if (!args.force) { const exists = await fs.stat(destination).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(error)); if (exists) throw new Error(`OAuth configuration already exists at ${destination}; use --force only for deliberate key rotation`); } const key = await JoseKey.generate(["ES256"], `thoughtstream-${new Date().toISOString().slice(0, 10)}`); if (!key.privateJwk) throw new Error("Generated OAuth client key has no private material"); const lines = [ "OAUTH_ENABLED=1", `OAUTH_PUBLIC_ORIGIN=${origin.origin}`, `OAUTH_ALLOWED_DID=${did}`, `OAUTH_EXPECTED_HANDLE=${handle}`, `OAUTH_STORE_DIR=${storeDirectory}`, `OAUTH_STORE_KEY_B64=${randomBytes(32).toString("base64")}`, `OAUTH_PRIVATE_JWK_B64=${Buffer.from(JSON.stringify(key.privateJwk), "utf8").toString("base64")}`, "OAUTH_SESSION_TTL_MS=43200000", "", ]; const temporary = `${destination}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`; await fs.writeFile(temporary, lines.join("\n"), { mode: 0o600, flag: "wx" }); await fs.rename(temporary, destination); await fs.chmod(destination, 0o600); process.stdout.write(`Wrote owner-only OAuth configuration to ${destination}\n`); process.stdout.write("Basic fallback configuration is independent and defaults to disabled when absent.\n"); function parseArgs(values: string[]): Record { const result: Record = {}; for (let index = 0; index < values.length; index += 1) { const current = values[index]!; if (current === "--") continue; if (current === "--force") { result.force = true; continue; } if (!["--origin", "--did", "--handle"].includes(current)) throw new Error(`Unknown argument: ${current}`); const value = values[index + 1]; if (!value) throw new Error(`Missing value for ${current}`); result[current.slice(2)] = value; index += 1; } return result; } function required(value: string | boolean | undefined, label: string): string { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`); return value.trim(); } async function refuseSymlink(target: string): Promise { const stat = await fs.lstat(target).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return undefined; throw error; }); if (stat?.isSymbolicLink()) throw new Error(`Refusing symlinked directory: ${target}`); }