Compare commits
1 Commits
docs/insta
...
fix/gatewa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca214ccc76 |
@@ -74,7 +74,8 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
||||
};
|
||||
|
||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
||||
// 0o600: owner read/write only — the session cookie is a credential
|
||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,10 +126,18 @@ export function registerGatewayCommand(program: Command): void {
|
||||
.description('Sign in to the gateway (defaults to URL from meta.json)')
|
||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
||||
.option('-e, --email <email>', 'Email address')
|
||||
.option('-p, --password <password>', 'Password')
|
||||
.option(
|
||||
'-p, --password <password>',
|
||||
'[UNSAFE] Avoid — exposes credentials in shell history and process listings',
|
||||
)
|
||||
.action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => {
|
||||
const { runLogin } = await import('./gateway/login.js');
|
||||
const url = getGatewayUrl(cmdOpts.gateway);
|
||||
if (cmdOpts.password) {
|
||||
console.warn(
|
||||
'Warning: --password flag exposes credentials in shell history and process listings.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password });
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,62 @@ import { createInterface } from 'node:readline';
|
||||
import { signIn, saveSession } from '../../auth.js';
|
||||
import { readMeta } from './daemon.js';
|
||||
|
||||
/**
|
||||
* Prompt for a single line of input (with echo).
|
||||
*/
|
||||
export function promptLine(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for a secret value without echoing the typed characters to the terminal.
|
||||
* Uses TTY raw mode when available so that passwords do not appear in terminal
|
||||
* recordings, scrollback, or shared screen sessions.
|
||||
*/
|
||||
export function promptSecret(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(question);
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf-8');
|
||||
|
||||
let secret = '';
|
||||
const onData = (char: string): void => {
|
||||
if (char === '\n' || char === '\r' || char === '\u0004') {
|
||||
process.stdout.write('\n');
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener('data', onData);
|
||||
resolve(secret);
|
||||
} else if (char === '\u0003') {
|
||||
// ^C
|
||||
process.stdout.write('\n');
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener('data', onData);
|
||||
process.exit(130);
|
||||
} else if (char === '\u007f' || char === '\b') {
|
||||
secret = secret.slice(0, -1);
|
||||
} else {
|
||||
secret += char;
|
||||
}
|
||||
};
|
||||
process.stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared login helper used by both `mosaic login` and `mosaic gateway login`.
|
||||
* Prompts for email/password if not supplied, signs in, and persists the session.
|
||||
@@ -11,17 +67,9 @@ export async function runLogin(opts: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}): Promise<void> {
|
||||
let email = opts.email;
|
||||
let password = opts.password;
|
||||
|
||||
if (!email || !password) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
if (!email) email = await ask('Email: ');
|
||||
if (!password) password = await ask('Password: ');
|
||||
rl.close();
|
||||
}
|
||||
const email = opts.email ?? (await promptLine('Email: '));
|
||||
// Do not trim password — it may intentionally contain leading/trailing whitespace
|
||||
const password = opts.password ?? (await promptSecret('Password: '));
|
||||
|
||||
const auth = await signIn(opts.gatewayUrl, email, password);
|
||||
saveSession(opts.gatewayUrl, auth);
|
||||
|
||||
@@ -16,14 +16,9 @@ vi.mock('./daemon.js', () => ({
|
||||
|
||||
vi.mock('./login.js', () => ({
|
||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||
}));
|
||||
|
||||
// Mock readline so tests don't block on stdin
|
||||
vi.mock('node:readline', () => ({
|
||||
createInterface: vi.fn().mockReturnValue({
|
||||
question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')),
|
||||
close: vi.fn(),
|
||||
}),
|
||||
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
||||
promptLine: vi.fn().mockResolvedValue('test@example.com'),
|
||||
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||
import { readMeta, writeMeta } from './daemon.js';
|
||||
import { getGatewayUrl } from './login.js';
|
||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||
|
||||
interface MintedToken {
|
||||
id: string;
|
||||
@@ -58,6 +57,9 @@ export async function mintAdminToken(
|
||||
|
||||
/**
|
||||
* 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() ?? {
|
||||
@@ -68,6 +70,15 @@ export function persistToken(gatewayUrl: string, minted: MintedToken): void {
|
||||
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)}...`;
|
||||
@@ -108,13 +119,10 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||
}
|
||||
|
||||
// Prompt for credentials
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
||||
|
||||
const email = (await ask('Email: ')).trim();
|
||||
const password = (await ask('Password: ')).trim();
|
||||
rl.close();
|
||||
// Prompt for credentials — password must not be echoed to the terminal
|
||||
const email = await promptLine('Email: ');
|
||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||
const password = await promptSecret('Password: ');
|
||||
|
||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
|
||||
Reference in New Issue
Block a user