diff --git a/README.md b/README.md index e58d589..437c43f 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Open a new shell, then try `ct sta`, `ct state rm ` or `ct plan --env ```bash # The host is captured at login and stored with the token; CT_HOST overrides it for CI. ct auth login --host https://mychurch.church.tools --token +ct auth login # or: prompt for host, then username+password (+2FA), token, or skip ct auth status # who am I? (`--env ` asks on another instance) ct get groups # JSON to stdout — pipe into jq (every page, not just the first) @@ -142,6 +143,15 @@ ct apply # create + update in dependency order (confirm + b ct refresh --group # make ChurchTools re-evaluate one auto-group now ``` +Run without `--token`, `ct auth login` asks how you want to authenticate: **username and +password** (completing a 2FA code when the instance asks for one, then fetching your +personal login token over the API), an **existing login token**, or **skip**. Password, +code and token prompts do not echo, and none of the three is ever stored, logged or +printed — only the login token and its host reach the Keychain. There is deliberately no +`--password` flag, so a password can never land in shell history or in `ps`. On Linux and +Windows there is no Keychain to store anything in, so the prompt is not offered at all: +export `CT_HOST` and `CT_LOGINTOKEN` there. + `state rm` is the inverse of `adopt`, and only of `adopt`: it removes the entry from the state file, makes no HTTP call, and leaves the resource in place in ChurchTools, now unmanaged. It refuses a key the config still declares — that diff --git a/src/auth/login.ts b/src/auth/login.ts new file mode 100644 index 0000000..f546a25 --- /dev/null +++ b/src/auth/login.ts @@ -0,0 +1,375 @@ +/** + * Interactive bootstrap of a ChurchTools **personal login token** (#138). + * + * Copying a token out of the ChurchTools web UI is the first thing a new user + * has to do and the first thing they get wrong. ChurchTools can hand the token + * out over the API instead: log in with username + password, complete TOTP when + * the instance asks for it, then read the persistent token for the person who + * just authenticated. + * + * ```text + * How do you want to authenticate? + * 1. Username and password + * 2. Existing login token + * 3. Skip login + * ``` + * + * Security properties this module is responsible for — all of them tested: + * + * - The username, password and TOTP code exist only as locals for the duration + * of the two requests that consume them. Nothing but `{host, token}` is ever + * handed to the credential store, and there is deliberately **no password + * flag**: a password on the command line lands in shell history and in `ps`. + * - No secret is ever printed. Every message derived from a ChurchTools response + * passes through {@link redactSecrets}, and request bodies are never echoed + * into an error — an authentication failure reports the HTTP status and CT's + * own (redacted) message, never what was sent. + * - Password / TOTP / token prompts do not echo (see `askHidden`). + * - On a platform with no credential store, the flow refuses up front rather + * than collecting a password it could only discard (`isSecureStorageAvailable`). + * + * The session cookie is handled locally rather than through `CtClient`: the + * login → TOTP pair must ride ONE session, and `CtClient`'s handshake is built + * around the login token that this module exists to obtain. Verification of the + * resulting token goes back through `CtClient.authenticate` unchanged. + */ +import { fetchWithRetry } from "../api/http.js"; +import { askHidden, askVisible } from "../ui/prompt.js"; +import { isSecureStorageAvailable } from "./tokenStore.js"; + +/** Prompt surface, injectable so the whole flow is testable without a terminal. */ +export interface LoginPrompts { + /** Ask for a non-secret value (username, choice, host). Echoes. */ + ask: (question: string) => Promise; + /** Ask for a secret (password, TOTP code, token). Must NOT echo. */ + askSecret: (question: string) => Promise; + /** Informational line for the user. Never called with a secret. */ + notify: (message: string) => void; +} + +export interface BootstrapDeps { + prompts?: Partial; + /** Defaults to `process.stdin.isTTY`. A non-TTY never prompts. */ + isTTY?: boolean; + /** Defaults to {@link isSecureStorageAvailable}. */ + secureStorage?: boolean; + fetchImpl?: typeof fetch; +} + +/** + * What the interactive flow produced. + * + * - `token` — a personal login token, from either the password flow or the + * user's clipboard. The caller verifies and stores it. + * - `skipped` — the user chose not to log in (or there is no terminal to ask + * on). `hint` is the command to run later. + * - `unsupported` — this platform has no credential store, so nothing was + * collected. `hint` is the environment-variable guidance. + */ +export type BootstrapOutcome = + | { kind: "token"; token: string } + | { kind: "skipped"; hint: string } + | { kind: "unsupported"; hint: string }; + +/** The command a user runs to authenticate later, once they have a token. */ +export function loginHint(host?: string): string { + return `ct auth login${host ? ` --host ${host}` : " --host "} --token `; +} + +/** The guidance for platforms without a credential store — unchanged from the README. */ +export function envVarHint(host?: string): string { + return `export CT_HOST=${host ?? ""} and export CT_LOGINTOKEN=`; +} + +/** + * Remove every known secret from a string before it is shown to anyone. + * + * The belt to the braces of "never build a message out of a secret in the first + * place": ChurchTools' own error messages are surfaced so a wrong password says + * so, and this guarantees that even a server that echoed the credential back + * cannot make it reach the terminal. Empty/short values are ignored so a + * one-character password does not shred the message. + */ +export function redactSecrets(text: string, secrets: (string | undefined)[]): string { + let result = text; + for (const secret of secrets) { + if (!secret || secret.length < 2) { + continue; + } + result = result.split(secret).join("[redacted]"); + } + return result; +} + +/** An authentication failure. Carries a status, never a request body. */ +export class LoginError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "LoginError"; + } +} + +function defaultPrompts(): LoginPrompts { + return { + ask: askVisible, + askSecret: askHidden, + notify: (message: string) => process.stderr.write(`${message}\n`), + }; +} + +/** Merge the `Set-Cookie`s of one response into a `Cookie` header value. */ +function mergeCookies(current: string, res: Response): string { + const setCookies = res.headers.getSetCookie?.() ?? []; + if (setCookies.length === 0) { + return current; + } + const jar = new Map( + current + .split("; ") + .filter(Boolean) + .map((pair) => { + const eq = pair.indexOf("="); + return [pair.slice(0, eq), pair.slice(eq + 1)] as [string, string]; + }), + ); + for (const raw of setCookies) { + const first = raw.split(";", 1)[0] ?? ""; + const eq = first.indexOf("="); + if (eq > 0) { + jar.set(first.slice(0, eq), first.slice(eq + 1)); + } + } + return [...jar].map(([k, v]) => `${k}=${v}`).join("; "); +} + +async function readJson(res: Response): Promise> { + try { + const text = await res.text(); + const parsed: unknown = text.trim() === "" ? {} : JSON.parse(text); + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** + * ChurchTools is inconsistent about its envelope: `status` and `personId` turn + * up at the top level on some releases and inside `data` on others. Look in + * both rather than pinning one shape. + */ +function pick(body: Record, key: string): unknown { + if (body[key] !== undefined) { + return body[key]; + } + const data = body.data; + if (typeof data === "object" && data !== null) { + return (data as Record)[key]; + } + return undefined; +} + +function messageOf(body: Record): string | undefined { + for (const key of ["translatedMessage", "message"]) { + const value = pick(body, key); + if (typeof value === "string" && value.trim() !== "") { + return value.trim(); + } + } + return undefined; +} + +function personIdOf(body: Record): number | undefined { + const value = pick(body, "personId"); + if (typeof value === "number") { + return value; + } + if (typeof value === "string" && /^\d+$/.test(value)) { + return Number.parseInt(value, 10); + } + return undefined; +} + +/** Fail with CT's own explanation when there is one — redacted, never with the body we sent. */ +function fail( + what: string, + res: Response, + body: Record, + secrets: (string | undefined)[], +): never { + const detail = messageOf(body); + const message = detail ? `${what}: ${redactSecrets(detail, secrets)}` : what; + throw new LoginError(`${message} (HTTP ${res.status})`, res.status); +} + +async function postJson( + fetchImpl: typeof fetch, + url: string, + cookie: string, + body: Record, +): Promise { + return fetchWithRetry( + url, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...(cookie ? { Cookie: cookie } : {}), + }, + body: JSON.stringify(body), + }, + // A login POST is not idempotent, so 5xx is not retried; a 429 still is. + { isIdempotent: false, fetchImpl }, + ); +} + +/** + * The password → (TOTP) → token exchange. + * + * `POST /api/login`, then — when the instance answers `status: "totp"` — + * `POST /api/login/totp` on the SAME session cookie, then + * `GET /api/persons/{personId}/logintoken` for the persistent token. + * + * Fetching the login token of the person who just authenticated is + * authentication, not people management: it reads nothing about anyone else and + * writes nothing at all. + * + * The password and code are parameters, never fields — they go out of scope + * with this call. + */ +export async function loginWithPassword( + host: string, + username: string, + password: string, + opts: { fetchImpl?: typeof fetch; askTotp?: () => Promise } = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const secrets: (string | undefined)[] = [password]; + let cookie = ""; + + const loginRes = await postJson(fetchImpl, `${host}/api/login`, cookie, { username, password }); + cookie = mergeCookies(cookie, loginRes); + const loginBody = await readJson(loginRes); + if (!loginRes.ok) { + fail("Login failed", loginRes, loginBody, secrets); + } + + const status = pick(loginBody, "status"); + let personId = personIdOf(loginBody); + + if (status === "totp") { + if (personId === undefined) { + throw new LoginError("ChurchTools asked for a 2FA code but returned no personId.", loginRes.status); + } + if (!opts.askTotp) { + throw new LoginError( + "This account requires a 2FA code, which needs an interactive terminal.", + loginRes.status, + ); + } + const code = (await opts.askTotp()).trim(); + secrets.push(code); + if (!/^\d{6}$/.test(code)) { + throw new LoginError("The 2FA code must be six digits.", 0); + } + const totpRes = await postJson(fetchImpl, `${host}/api/login/totp`, cookie, { code, personId }); + cookie = mergeCookies(cookie, totpRes); + const totpBody = await readJson(totpRes); + if (!totpRes.ok) { + fail("2FA verification failed", totpRes, totpBody, secrets); + } + personId = personIdOf(totpBody) ?? personId; + } + + if (personId === undefined) { + throw new LoginError("ChurchTools accepted the login but returned no personId.", loginRes.status); + } + + const tokenRes = await fetchWithRetry( + `${host}/api/persons/${personId}/logintoken`, + { headers: { Accept: "application/json", ...(cookie ? { Cookie: cookie } : {}) } }, + { isIdempotent: true, fetchImpl }, + ); + const tokenBody = await readJson(tokenRes); + if (!tokenRes.ok) { + fail("Could not read the personal login token", tokenRes, tokenBody, secrets); + } + const raw = tokenBody.data ?? tokenBody; + const token = + typeof raw === "string" + ? raw + : typeof raw === "object" && raw !== null + ? (raw as Record).token + : undefined; + if (typeof token !== "string" || token.trim() === "") { + throw new LoginError("ChurchTools returned no login token for this account.", tokenRes.status); + } + return token.trim(); +} + +/** + * The interactive flow, factored out of `ct auth login` so `ct init` (#131) can + * run the very same prompts in its own sequence — call it with the host the + * user just chose and store the returned token through `storeCredentials`. + * + * Never prompts on a non-TTY, and never collects anything on a platform without + * a credential store. + */ +export async function bootstrapLoginToken(host: string, deps: BootstrapDeps = {}): Promise { + const prompts: LoginPrompts = { ...defaultPrompts(), ...deps.prompts }; + const isTTY = deps.isTTY ?? Boolean(process.stdin.isTTY); + const secureStorage = deps.secureStorage ?? isSecureStorageAvailable(); + const fetchImpl = deps.fetchImpl ?? fetch; + + if (!secureStorage) { + // No store to put a token in, so nothing is asked for. Collecting a password + // here would mean handling a secret only to throw it away (#138). + return { + kind: "unsupported", + hint: envVarHint(host), + }; + } + if (!isTTY) { + return { kind: "skipped", hint: loginHint(host) }; + } + + prompts.notify("How do you want to authenticate?"); + prompts.notify(" 1. Username and password"); + prompts.notify(" 2. Existing login token"); + prompts.notify(" 3. Skip login"); + const choice = (await prompts.ask("Choice [1]: ")).trim() || "1"; + + switch (choice) { + case "1": { + const username = (await prompts.ask("Username or email: ")).trim(); + if (!username) { + throw new LoginError("No username given.", 0); + } + // `password` is a local: it dies with this block, and is never stored, + // logged, or passed anywhere but the login request. + const password = await prompts.askSecret("Password (not shown): "); + if (!password) { + throw new LoginError("No password given.", 0); + } + const token = await loginWithPassword(host, username, password, { + fetchImpl, + askTotp: () => prompts.askSecret("Six-digit 2FA code (not shown): "), + }); + return { kind: "token", token }; + } + case "2": { + const token = (await prompts.askSecret("Personal login token (not shown): ")).trim(); + if (!token) { + throw new LoginError("No token given.", 0); + } + return { kind: "token", token }; + } + case "3": + return { kind: "skipped", hint: loginHint(host) }; + default: + throw new LoginError(`Unknown choice "${choice}" — pick 1, 2 or 3.`, 0); + } +} diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts index b0af59e..2055fa7 100644 --- a/src/auth/tokenStore.ts +++ b/src/auth/tokenStore.ts @@ -41,6 +41,18 @@ function isMac(): boolean { return platform() === "darwin"; } +/** + * Whether this platform has a credential store `ct` can write to — today, the + * macOS Keychain and nothing else. Exported because callers need to know BEFORE + * they collect anything: an interactive login that gathered a password on Linux + * could only throw it away again at {@link storeCredentials}, having handled a + * secret for nothing. Those platforms keep the `CT_HOST` / `CT_LOGINTOKEN` + * guidance instead (#138). + */ +export function isSecureStorageAvailable(): boolean { + return isMac(); +} + /** Parse the stored blob. Returns null for anything that is not a well-formed {host, token}. */ export function parseCredentials(raw: string): Credentials | null { let parsed: unknown; diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 0941ec0..2a279df 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,13 +1,20 @@ import { Command } from "commander"; import { CtClient } from "../api/ctClient.js"; import { normalizeHost, resolveConfig } from "../config.js"; -import { storeCredentials, readToken, clearCredentials } from "../auth/tokenStore.js"; +import { + storeCredentials, + readToken, + clearCredentials, + isSecureStorageAvailable, +} from "../auth/tokenStore.js"; +import { bootstrapLoginToken } from "../auth/login.js"; +import { askVisible } from "../ui/prompt.js"; import { checkAllEnvAuth, renderEnvAuth, allEnvsAuthenticated } from "../auth/status.js"; import { authedSession } from "../api/session.js"; import { prepareEnvHost } from "../env/context.js"; import { loadEnvProfile, loadEnvProfiles, resolveEnvsPath } from "../env/envs.js"; import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "../api/version.js"; -import { success, error, info, warn, out } from "../ui.js"; +import { success, error, info, warn, out, formatError } from "../ui.js"; /** * The `--all` preflight: one line per environment, resolving each env's host and @@ -36,23 +43,58 @@ export function authCommand(): Command { cmd .command("login") - .description("Store and verify a ChurchTools host + personal login token") + .description( + "Store and verify a ChurchTools host + personal login token (prompts when --token is omitted)", + ) .option("-H, --host ", "ChurchTools host, e.g. https://mychurch.church.tools (or set CT_HOST)") .option("-t, --token ", "personal login token (or set CT_LOGINTOKEN)") .action(async (opts: { host?: string; token?: string }) => { - const rawHost = opts.host?.trim() || process.env.CT_HOST?.trim(); + const interactive = Boolean(process.stdin.isTTY); + let rawHost = opts.host?.trim() || process.env.CT_HOST?.trim(); + if (!rawHost && interactive && isSecureStorageAvailable()) { + rawHost = (await askVisible("ChurchTools host (e.g. https://mychurch.church.tools): ")).trim(); + } if (!rawHost) { error("No host provided. Pass --host or set CT_HOST."); process.exitCode = 1; return; } - const token = opts.token?.trim() || process.env.CT_LOGINTOKEN?.trim(); - if (!token) { + const config = { host: normalizeHost(rawHost) }; + + // The non-interactive contract is unchanged: a token from --token or + // CT_LOGINTOKEN is verified and stored exactly as before, and nothing + // prompts. Only the absence of one opens the guided flow (#138). + let token = opts.token?.trim() || process.env.CT_LOGINTOKEN?.trim(); + if (!token && !interactive) { error("No token provided. Pass --token or set CT_LOGINTOKEN."); process.exitCode = 1; return; } - const config = { host: normalizeHost(rawHost) }; + if (!token) { + let outcome; + try { + outcome = await bootstrapLoginToken(config.host); + } catch (err) { + // formatError never sees a secret: LoginError carries a status and a + // redacted message, never the request body that was sent. + error(formatError(err)); + process.exitCode = 1; + return; + } + if (outcome.kind === "unsupported") { + error( + "Credential storage requires the macOS Keychain. " + `On this platform, ${outcome.hint} instead.`, + ); + process.exitCode = 1; + return; + } + if (outcome.kind === "skipped") { + info(`Skipped — no credentials stored. To log in later: ${outcome.hint}`); + return; + } + token = outcome.token; + } + const client = new CtClient(config); const me = await client.authenticate(token); const location = await storeCredentials({ host: config.host, token }); diff --git a/src/ui/prompt.ts b/src/ui/prompt.ts index 6930ea6..8a19085 100644 --- a/src/ui/prompt.ts +++ b/src/ui/prompt.ts @@ -20,6 +20,34 @@ function realAsk(question: string): Promise { }); } +/** + * Ask a question whose answer must never appear on screen — a password, a TOTP + * code, a login token (#138). + * + * `readline` echoes typed characters through its private `_writeToOutput`. The + * prompt itself is written synchronously by `rl.question`, so muting straight + * after it lets the label through and swallows every keystroke after it. The + * trailing newline is written by hand, since the muted Enter no longer produces + * one. + */ +export function askHidden(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + const muteable = rl as unknown as { _writeToOutput: (chunk: string) => void }; + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + process.stderr.write("\n"); + resolve(answer); + }); + muteable._writeToOutput = () => {}; + }); +} + +/** Ask a question whose answer is not secret and may echo normally. */ +export function askVisible(question: string): Promise { + return realAsk(question); +} + function ttyState(opts: PromptOptions): boolean { return opts.isTTY ?? Boolean(process.stdin.isTTY); } diff --git a/tests/auth-login-bootstrap.test.ts b/tests/auth-login-bootstrap.test.ts new file mode 100644 index 0000000..6ace7be --- /dev/null +++ b/tests/auth-login-bootstrap.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect } from "vitest"; +import { + bootstrapLoginToken, + loginWithPassword, + redactSecrets, + LoginError, + loginHint, + envVarHint, + type LoginPrompts, +} from "../src/auth/login.js"; +import { isSecureStorageAvailable } from "../src/auth/tokenStore.js"; + +// A host that cannot resolve: if a stub is ever missed, the test fails loudly +// instead of reaching a real instance. +const HOST = "https://ct-cli.invalid"; +const PASSWORD = "hunter2-correct-horse"; +const TOTP = "123456"; +const TOKEN = "tok_abcdef0123456789"; + +interface Call { + url: string; + method: string; + body?: string; + cookie?: string; +} + +/** A fetch stub that records every request and answers from a scripted queue. */ +function stubFetch(responses: { status?: number; body?: unknown; setCookie?: string[] }[]): { + fetchImpl: typeof fetch; + calls: Call[]; +} { + const calls: Call[] = []; + let i = 0; + const fetchImpl = (async (input: string | URL, init: RequestInit = {}) => { + const headers = (init.headers ?? {}) as Record; + calls.push({ + url: String(input), + method: init.method ?? "GET", + body: typeof init.body === "string" ? init.body : undefined, + cookie: headers.Cookie, + }); + const spec = responses[i++] ?? { status: 500, body: {} }; + const res = new Response(JSON.stringify(spec.body ?? {}), { + status: spec.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + if (spec.setCookie) { + for (const cookie of spec.setCookie) { + res.headers.append("Set-Cookie", cookie); + } + } + return res; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +/** Prompts that answer from a script and record everything shown to the user. */ +function scriptedPrompts(script: { ask?: string[]; secret?: string[] }): { + prompts: LoginPrompts; + shown: string[]; +} { + const shown: string[] = []; + const asks = [...(script.ask ?? [])]; + const secrets = [...(script.secret ?? [])]; + return { + shown, + prompts: { + ask: async (question) => { + shown.push(question); + const next = asks.shift(); + if (next === undefined) { + throw new Error(`unexpected visible prompt: ${question}`); + } + return next; + }, + askSecret: async (question) => { + shown.push(question); + const next = secrets.shift(); + if (next === undefined) { + throw new Error(`unexpected secret prompt: ${question}`); + } + return next; + }, + notify: (message) => shown.push(message), + }, + }; +} + +const OK_LOGIN = { status: 200, body: { data: { personId: 42, status: "success" } } }; +const TOTP_CHALLENGE = { status: 200, body: { data: { personId: 42, status: "totp" } } }; +const TOKEN_RESPONSE = { status: 200, body: { data: TOKEN } }; + +describe("loginWithPassword (#138)", () => { + it("logs in without 2FA and returns the personal login token", async () => { + const { fetchImpl, calls } = stubFetch([ + { ...OK_LOGIN, setCookie: ["ChurchTools_ct=sess1; Path=/; HttpOnly"] }, + TOKEN_RESPONSE, + ]); + const token = await loginWithPassword(HOST, "ada@example.org", PASSWORD, { fetchImpl }); + expect(token).toBe(TOKEN); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `POST ${HOST}/api/login`, + `GET ${HOST}/api/persons/42/logintoken`, + ]); + // The token read rides the session cookie the login handed out. + expect(calls[1]?.cookie).toContain("ChurchTools_ct=sess1"); + }); + + it("detects the TOTP challenge and completes it on the SAME session cookie", async () => { + const { fetchImpl, calls } = stubFetch([ + { ...TOTP_CHALLENGE, setCookie: ["ChurchTools_ct=sess1; Path=/"] }, + { status: 200, body: { data: { personId: 42, status: "success" } } }, + TOKEN_RESPONSE, + ]); + const token = await loginWithPassword(HOST, "ada", PASSWORD, { + fetchImpl, + askTotp: async () => TOTP, + }); + expect(token).toBe(TOKEN); + expect(calls[1]?.url).toBe(`${HOST}/api/login/totp`); + expect(JSON.parse(calls[1]?.body ?? "{}")).toEqual({ code: TOTP, personId: 42 }); + expect(calls[1]?.cookie).toContain("ChurchTools_ct=sess1"); + expect(calls[2]?.cookie).toContain("ChurchTools_ct=sess1"); + }); + + it("does not ask for a code when the instance does not challenge", async () => { + const { fetchImpl } = stubFetch([OK_LOGIN, TOKEN_RESPONSE]); + let asked = false; + await loginWithPassword(HOST, "ada", PASSWORD, { + fetchImpl, + askTotp: async () => { + asked = true; + return TOTP; + }, + }); + expect(asked).toBe(false); + }); + + it("reads a top-level envelope as well as a nested one", async () => { + const { fetchImpl } = stubFetch([ + { status: 200, body: { personId: 7, status: "success" } }, + { status: 200, body: { data: { token: TOKEN } } }, + ]); + await expect(loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl })).resolves.toBe(TOKEN); + }); + + it("rejects invalid credentials with CT's message and the status, and no request body", async () => { + const { fetchImpl } = stubFetch([ + { status: 401, body: { message: "Wrong username or password", translatedMessage: "" } }, + ]); + const err = await loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(LoginError); + expect((err as LoginError).status).toBe(401); + expect((err as LoginError).message).toContain("Wrong username or password"); + expect((err as LoginError).message).toContain("401"); + expect((err as LoginError).message).not.toContain(PASSWORD); + }); + + it("rejects an invalid TOTP code without leaking the code", async () => { + const { fetchImpl } = stubFetch([ + { ...TOTP_CHALLENGE, setCookie: ["ct=1"] }, + { status: 403, body: { message: "Invalid code" } }, + ]); + const err = await loginWithPassword(HOST, "ada", PASSWORD, { + fetchImpl, + askTotp: async () => "999999", + }).catch((e: unknown) => e); + expect((err as LoginError).message).toContain("2FA verification failed"); + expect((err as LoginError).message).not.toContain("999999"); + expect((err as LoginError).message).not.toContain(PASSWORD); + }); + + it("refuses a code that is not six digits before sending it anywhere", async () => { + const { fetchImpl, calls } = stubFetch([TOTP_CHALLENGE]); + await expect( + loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl, askTotp: async () => "12" }), + ).rejects.toThrow(/six digits/); + expect(calls).toHaveLength(1); // the login only — no /login/totp call + }); + + it("fails clearly when a 2FA challenge arrives with no way to answer it", async () => { + const { fetchImpl } = stubFetch([TOTP_CHALLENGE]); + await expect(loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl })).rejects.toThrow( + /interactive terminal/, + ); + }); + + it("fails when the instance returns no token", async () => { + const { fetchImpl } = stubFetch([OK_LOGIN, { status: 200, body: { data: "" } }]); + await expect(loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl })).rejects.toThrow(/no login token/); + }); +}); + +describe("bootstrapLoginToken choices (#138)", () => { + it("choice 1 runs the password flow and returns the token", async () => { + const { fetchImpl } = stubFetch([OK_LOGIN, TOKEN_RESPONSE]); + const { prompts } = scriptedPrompts({ ask: ["1", "ada@example.org"], secret: [PASSWORD] }); + const outcome = await bootstrapLoginToken(HOST, { + prompts, + isTTY: true, + secureStorage: true, + fetchImpl, + }); + expect(outcome).toEqual({ kind: "token", token: TOKEN }); + }); + + it("defaults to choice 1 on an empty answer", async () => { + const { fetchImpl } = stubFetch([OK_LOGIN, TOKEN_RESPONSE]); + const { prompts } = scriptedPrompts({ ask: ["", "ada"], secret: [PASSWORD] }); + const outcome = await bootstrapLoginToken(HOST, { + prompts, + isTTY: true, + secureStorage: true, + fetchImpl, + }); + expect(outcome.kind).toBe("token"); + }); + + it("choice 2 keeps the existing-token path: asked hidden, returned untouched", async () => { + const { prompts, shown } = scriptedPrompts({ ask: ["2"], secret: [` ${TOKEN} `] }); + const outcome = await bootstrapLoginToken(HOST, { + prompts, + isTTY: true, + secureStorage: true, + fetchImpl: (() => { + throw new Error("the token path must not call the API itself"); + }) as unknown as typeof fetch, + }); + expect(outcome).toEqual({ kind: "token", token: TOKEN }); + // The token was asked for through the SECRET prompt, so it never echoes. + expect(shown).toContain("Personal login token (not shown): "); + }); + + it("choice 3 skips and hands back an actionable command", async () => { + const { prompts } = scriptedPrompts({ ask: ["3"] }); + const outcome = await bootstrapLoginToken(HOST, { prompts, isTTY: true, secureStorage: true }); + expect(outcome).toEqual({ kind: "skipped", hint: loginHint(HOST) }); + expect(loginHint(HOST)).toContain("ct auth login"); + }); + + it("rejects an unknown choice", async () => { + const { prompts } = scriptedPrompts({ ask: ["9"] }); + await expect(bootstrapLoginToken(HOST, { prompts, isTTY: true, secureStorage: true })).rejects.toThrow( + /pick 1, 2 or 3/, + ); + }); + + it("never prompts on a non-TTY", async () => { + const outcome = await bootstrapLoginToken(HOST, { + isTTY: false, + secureStorage: true, + prompts: { + ask: async () => { + throw new Error("must not prompt"); + }, + askSecret: async () => { + throw new Error("must not prompt"); + }, + }, + }); + expect(outcome.kind).toBe("skipped"); + }); +}); + +describe("platform without secure credential storage (#138)", () => { + it("collects nothing and keeps the environment-variable guidance", async () => { + const outcome = await bootstrapLoginToken(HOST, { + isTTY: true, + secureStorage: false, + prompts: { + ask: async () => { + throw new Error("no credential may be collected without a store to put it in"); + }, + askSecret: async () => { + throw new Error("no credential may be collected without a store to put it in"); + }, + }, + }); + expect(outcome).toEqual({ kind: "unsupported", hint: envVarHint(HOST) }); + expect(envVarHint(HOST)).toContain("CT_LOGINTOKEN"); + expect(envVarHint(HOST)).toContain("CT_HOST"); + }); + + it("isSecureStorageAvailable tracks the platform the Keychain lives on", () => { + expect(isSecureStorageAvailable()).toBe(process.platform === "darwin"); + }); +}); + +describe("secret redaction (#138)", () => { + it("scrubs every known secret out of a message", () => { + const text = `sent ${PASSWORD} and ${TOTP} and ${TOKEN}`; + const clean = redactSecrets(text, [PASSWORD, TOTP, TOKEN, undefined, ""]); + expect(clean).not.toContain(PASSWORD); + expect(clean).not.toContain(TOTP); + expect(clean).not.toContain(TOKEN); + expect(clean).toContain("[redacted]"); + }); + + it("redacts a server that echoes the credentials straight back", async () => { + const { fetchImpl } = stubFetch([ + { status: 400, body: { message: `Bad request: {"password":"${PASSWORD}"}` } }, + ]); + const err = await loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl }).catch((e: unknown) => e); + expect((err as Error).message).not.toContain(PASSWORD); + expect((err as Error).message).toContain("[redacted]"); + }); + + it("no secret reaches the terminal anywhere in the interactive flow", async () => { + const { fetchImpl } = stubFetch([ + { ...TOTP_CHALLENGE, setCookie: ["ct=1"] }, + { status: 200, body: { data: { personId: 42 } } }, + TOKEN_RESPONSE, + ]); + const { prompts, shown } = scriptedPrompts({ + ask: ["1", "ada@example.org"], + secret: [PASSWORD, TOTP], + }); + const outcome = await bootstrapLoginToken(HOST, { + prompts, + isTTY: true, + secureStorage: true, + fetchImpl, + }); + expect(outcome).toEqual({ kind: "token", token: TOKEN }); + const transcript = shown.join("\n"); + for (const secret of [PASSWORD, TOTP, TOKEN]) { + expect(transcript).not.toContain(secret); + } + }); + + it("the password and code are sent once and only to the login endpoints", async () => { + const { fetchImpl, calls } = stubFetch([ + { ...TOTP_CHALLENGE, setCookie: ["ct=1"] }, + { status: 200, body: { data: { personId: 42 } } }, + TOKEN_RESPONSE, + ]); + await loginWithPassword(HOST, "ada", PASSWORD, { fetchImpl, askTotp: async () => TOTP }); + const withPassword = calls.filter((c) => c.body?.includes(PASSWORD)); + const withCode = calls.filter((c) => c.body?.includes(TOTP)); + expect(withPassword.map((c) => c.url)).toEqual([`${HOST}/api/login`]); + expect(withCode.map((c) => c.url)).toEqual([`${HOST}/api/login/totp`]); + // Nothing secret ever rides in a URL, where it would land in an access log. + for (const call of calls) { + expect(call.url).not.toContain(PASSWORD); + expect(call.url).not.toContain(TOTP); + } + }); +}); + +describe("ct auth login flags (#138)", () => { + it("offers no password flag — a password must never reach the shell history", async () => { + const { authCommand } = await import("../src/commands/auth.js"); + const login = authCommand() + .commands.find((c) => c.name() === "login") + ?.options.map((o) => o.flags) + .join(" "); + expect(login).toBeDefined(); + expect(login).not.toMatch(/password/i); + expect(login).not.toMatch(/--totp|--code|--user(name)?/i); + }); +});