import { loadSession, validateSession, signIn, saveSession } from '../../auth.js'; import { readMeta, writeMeta } from './daemon.js'; import { getGatewayUrl, promptLine, promptSecret } from './login.js'; import { readCredentialsFromPipedStdin } from './piped-credentials.js'; interface MintedToken { id: string; label: string; plaintext: string; } /** * Call POST /api/admin/tokens with the session cookie and return the minted token. * Exits the process on network or auth errors. */ export async function mintAdminToken( gatewayUrl: string, cookie: string, label: string, ): Promise { let res: Response; try { res = await fetch(`${gatewayUrl}/api/admin/tokens`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: gatewayUrl, }, body: JSON.stringify({ label, scope: 'admin' }), }); } catch (err) { console.error( `Could not reach gateway at ${gatewayUrl}: ${err instanceof Error ? err.message : String(err)}`, ); process.exit(1); } if (res.status === 401 || res.status === 403) { console.error( `Session rejected by the gateway (${res.status.toString()}) — your session may be expired.`, ); console.error('Run: mosaic gateway login'); process.exit(2); } if (!res.ok) { const body = await res.text().catch(() => ''); console.error( `Gateway rejected token creation (${res.status.toString()}): ${body.slice(0, 200)}`, ); process.exit(3); } const data = (await res.json()) as { id: string; label: string; plaintext: string }; return { id: data.id, label: data.label, plaintext: data.plaintext }; } /** * Persist the new token into meta.json and print the confirmation banner. * * Emits a warning when the target gateway differs from the locally installed one, * so operators are aware that meta.json may not reflect the intended gateway. */ export function persistToken(gatewayUrl: string, minted: MintedToken): void { const meta = readMeta() ?? { version: 'unknown', installedAt: new Date().toISOString(), entryPoint: '', host: new URL(gatewayUrl).hostname, port: parseInt(new URL(gatewayUrl).port || '14242', 10), }; // Warn when the target gateway does not match the locally installed one const targetHost = new URL(gatewayUrl).hostname; if (targetHost !== meta.host) { console.warn( `Warning: token was minted against ${gatewayUrl} but is being saved to the local` + ` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`, ); } writeMeta({ ...meta, adminToken: minted.plaintext }); const preview = `${minted.plaintext.slice(0, 8)}...`; console.log(); console.log(`Token minted: ${minted.label}`); console.log(`Preview: ${preview}`); console.log('Token saved to meta.json. Use it with admin endpoints.'); } /** * Require a valid session for the given gateway URL. * Returns the session cookie or exits if not authenticated. */ export async function requireSession(gatewayUrl: string): Promise { const session = loadSession(gatewayUrl); if (session) { const valid = await validateSession(gatewayUrl, session.cookie); if (valid) return session.cookie; } console.error('Not signed in or session expired.'); console.error('Run: mosaic gateway login'); process.exit(2); } /** * Ensure a valid session for the gateway, prompting for credentials if needed. * On sign-in failure, prints the error and exits non-zero. * Returns the session cookie. * * Credential precedence when sign-in is needed (#1394): * 1. explicit opts (--email flag; highest) * 2. non-TTY stdin — first line email, second line password (headless dual * path for callers without argv access: printf 'email\npassword\n' | …) * 3. interactive prompt (TTY only) */ export interface SessionCredentialOptions { /** Email from an explicit flag (argv). Highest precedence. */ email?: string; /** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */ password?: string; } export async function ensureSession( gatewayUrl: string, opts: SessionCredentialOptions = {}, ): Promise { // Try the stored session first const session = loadSession(gatewayUrl); if (session) { const valid = await validateSession(gatewayUrl, session.cookie); if (valid) return session.cookie; console.log('Stored session is invalid or expired. Please sign in again.'); } else { console.log(`No session found for ${gatewayUrl}. Please sign in.`); } let email = opts.email; let password = opts.password; if ((!email || !password) && !process.stdin.isTTY) { const piped = await readCredentialsFromPipedStdin(); email = email ?? piped.email ?? undefined; password = password ?? piped.password ?? undefined; } if (!email || !password) { if (!process.stdin.isTTY) { console.error( 'No valid session and no credentials available headlessly. Provide --email plus ' + "a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.", ); process.exit(2); } email = await promptLine('Email: '); // Do not trim password — it may contain intentional leading/trailing whitespace password = await promptSecret('Password: '); } const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(2); }); saveSession(gatewayUrl, auth); console.log(`Signed in as ${auth.email}`); return auth.cookie; } /** * `mosaic gateway config rotate-token` — requires an existing valid session. */ export async function runRotateToken(gatewayUrl?: string): Promise { const url = getGatewayUrl(gatewayUrl); const cookie = await requireSession(url); const label = `CLI rotated token (${new Date().toISOString().slice(0, 10)})`; const minted = await mintAdminToken(url, cookie, label); persistToken(url, minted); } /** * `mosaic gateway config recover-token` — signs in if no session exists. * Passes the --email flag through to ensureSession (#1394 dual path). */ export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise { const url = getGatewayUrl(gatewayUrl); const cookie = await ensureSession(url, { email }); const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`; const minted = await mintAdminToken(url, cookie, label); persistToken(url, minted); }