From 323f5c9a21bdc69432b599bafe86730b9909d425 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Tue, 2 Jun 2026 16:44:41 -0400 Subject: [PATCH 1/4] refactor(auth): propagate key_name/expires_at from config and narrow whoami mode --- src/lib/__tests__/auth.test.ts | 41 ++++++++++++++++++++++++++++++++++ src/lib/auth.ts | 27 +++++++++++++--------- src/types.ts | 2 ++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index 6614f1f..0067416 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -54,6 +54,47 @@ describe('resolveAuth', () => { expect(result).toEqual({ secretKey: 'sk_test_config', source: 'config' }) }) + test('propagates key_name and expires_at when source is config', () => { + vi.mocked(readConfig).mockReturnValue({ + secret_key: 'sk_test_config', + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + + const result = resolveAuth() + expect(result).toEqual({ + secretKey: 'sk_test_config', + source: 'config', + keyName: 'francisca-mac-cli', + expiresAt: '2026-08-16T12:00:00Z', + }) + }) + + test('does not propagate key_name or expires_at when source is flag', () => { + vi.mocked(readConfig).mockReturnValue({ + secret_key: 'sk_test_config', + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + + const result = resolveAuth({ apiKey: 'sk_test_flag' }) + expect(result.keyName).toBeUndefined() + expect(result.expiresAt).toBeUndefined() + }) + + test('does not propagate key_name or expires_at when source is env', () => { + process.env.FINTOC_API_KEY = 'sk_test_env' + vi.mocked(readConfig).mockReturnValue({ + secret_key: 'sk_test_config', + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + + const result = resolveAuth() + expect(result.keyName).toBeUndefined() + expect(result.expiresAt).toBeUndefined() + }) + test('throws when --api-key is empty string', () => { process.env.FINTOC_API_KEY = 'sk_test_env' vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_config' }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dce8369..6aa0dfc 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -8,20 +8,16 @@ export type AuthSource = 'flag' | 'env' | 'config' export type ResolvedAuth = { secretKey: string source: AuthSource + keyName?: string + expiresAt?: string } -export type WhoamiResponse = { - organizationName: string - mode: string - apiVersion: string -} - -export const resolveAuth = (options?: { apiKey?: string }) => { +export const resolveAuth = (options?: { apiKey?: string }): ResolvedAuth => { if (options?.apiKey !== undefined) { if (!options.apiKey.trim()) { throw new Error('API key is empty. Provide a valid key with --api-key or remove the flag.') } - return { secretKey: options.apiKey, source: 'flag' as const } + return { secretKey: options.apiKey, source: 'flag' } } const envKey = process.env.FINTOC_API_KEY @@ -29,12 +25,17 @@ export const resolveAuth = (options?: { apiKey?: string }) => { if (!envKey.trim()) { throw new Error('FINTOC_API_KEY is set but empty. Provide a valid key or unset the variable.') } - return { secretKey: envKey, source: 'env' as const } + return { secretKey: envKey, source: 'env' } } const config = readConfig() if (config.secret_key) { - return { secretKey: config.secret_key, source: 'config' as const } + return { + secretKey: config.secret_key, + source: 'config', + keyName: config.key_name, + expiresAt: config.expires_at, + } } throw new Error( @@ -59,9 +60,13 @@ export const createClient = (secretKey: string, jwsPrivateKey?: string) => { export const whoami = async (secretKey: string) => { const client = createClient(secretKey) const result = await client.whoami.get('whoami') + const mode = result.api_key.mode + if (mode !== 'test' && mode !== 'live') { + throw new Error(`Unexpected mode from API: ${mode}`) + } return { organizationName: result.organization.name, - mode: result.api_key.mode, + mode, apiVersion: result.organization.api_version instanceof Date ? result.organization.api_version.toISOString().slice(0, 10) diff --git a/src/types.ts b/src/types.ts index 86e4a7d..ab3deef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ export type FintocConfig = { secret_key?: string jws_private_key?: string + key_name?: string + expires_at?: string color?: boolean } From 3604aa9222db843e52b54ad75f2ce8b7c9739e36 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Tue, 2 Jun 2026 16:44:44 -0400 Subject: [PATCH 2/4] refactor(browser-login): add cancel handle --- src/lib/browser-login.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/browser-login.ts b/src/lib/browser-login.ts index be63366..812d45c 100644 --- a/src/lib/browser-login.ts +++ b/src/lib/browser-login.ts @@ -31,6 +31,7 @@ export type BrowserLoginOptions = { export type BrowserLoginSession = { url: string result: Promise + cancel: () => void } export type BrowserLoginErrorReason = 'denied' | 'timeout' | 'mismatch' @@ -200,6 +201,8 @@ export const startBrowserLogin = async ( }, timeoutMs) timeout.unref() + const cancel = () => reject(new Error('Browser login cancelled')) + const result = baseResult.finally(() => { clearTimeout(timeout) server.close() @@ -276,5 +279,5 @@ export const startBrowserLogin = async ( openInBrowser(url).catch(() => {}) - return { url, result } + return { url, result, cancel } } From 6469f51a90736864047a07190af9d3d799f41e23 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Tue, 2 Jun 2026 16:44:46 -0400 Subject: [PATCH 3/4] feat(config): show key_name and expires_at when available --- src/commands/__tests__/config.test.ts | 67 ++++++++++++++++++++++++++- src/commands/config.ts | 17 ++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/commands/__tests__/config.test.ts b/src/commands/__tests__/config.test.ts index 7d20bf2..64ef65d 100644 --- a/src/commands/__tests__/config.test.ts +++ b/src/commands/__tests__/config.test.ts @@ -1,3 +1,4 @@ +import type { FintocConfig } from '../../types.js' import { Command } from 'commander' import { beforeEach, describe, expect, test, vi } from 'vitest' import { resolveAuth, whoami } from '../../lib/auth.js' @@ -13,7 +14,7 @@ vi.mock('../../lib/auth.js', () => ({ vi.mock('../../lib/config.js', () => ({ CONFIG_PATH: '/mock-home/.fintoc/config.toml', - readConfig: vi.fn(), + readConfig: vi.fn(() => ({})), writeConfig: vi.fn(), })) @@ -39,11 +40,14 @@ describe('config show command', () => { vi.clearAllMocks() }) - const setupAuth = () => { + const setupAuth = (storedConfig: FintocConfig = { secret_key: 'sk_test_abc123' }) => { vi.mocked(resolveAuth).mockReturnValue({ secretKey: 'sk_test_abc123', source: 'config', + keyName: storedConfig.key_name, + expiresAt: storedConfig.expires_at, }) + vi.mocked(readConfig).mockReturnValue(storedConfig) vi.mocked(whoami).mockResolvedValue({ organizationName: 'Acme Corp', mode: 'test', @@ -161,6 +165,65 @@ describe('config show command', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('env var')) }) }) + + describe('when stored config has key_name and expires_at', () => { + test('shows them in the text output', async () => { + setupAuth({ + secret_key: 'sk_test_abc123', + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + + const program = createProgram() + await program.parseAsync(['config', 'show'], { from: 'user' }) + + expect(log).toHaveBeenCalledWith(expect.stringContaining('francisca-mac-cli')) + expect(log).toHaveBeenCalledWith(expect.stringContaining('2026-08-16T12:00:00Z')) + }) + + test('includes them in the JSON output', async () => { + setupAuth({ + secret_key: 'sk_test_abc123', + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + + const program = createProgram() + await program.parseAsync(['--json', 'config', 'show'], { from: 'user' }) + + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ + key_name: 'francisca-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }), + ) + }) + }) + + describe('when the active key comes from a flag/env (not the stored one)', () => { + test('does not leak the stored key_name', async () => { + vi.mocked(resolveAuth).mockReturnValue({ + secretKey: 'sk_test_inline', + source: 'flag', + }) + vi.mocked(readConfig).mockReturnValue({ + secret_key: 'sk_test_stored', + key_name: 'stored-name', + }) + vi.mocked(whoami).mockResolvedValue({ + organizationName: 'Acme', + mode: 'test', + apiVersion: '2023-03-15', + }) + + const program = createProgram() + await program.parseAsync(['--json', 'config', 'show'], { from: 'user' }) + + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ key_name: null, expires_at: null }), + ) + }) + }) }) describe('config set command', () => { diff --git a/src/commands/config.ts b/src/commands/config.ts index 12978a8..2087db3 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,4 +1,5 @@ import type { Command } from 'commander' +import type { AuthSource } from '../lib/auth.js' import type { FintocConfig } from '../types.js' import { maskKey, resolveAuth, whoami } from '../lib/auth.js' import { CONFIG_PATH, readConfig, writeConfig } from '../lib/config.js' @@ -21,13 +22,15 @@ export const configCommand = (program: Command) => { .description('Show current configuration') .action(async (_opts: unknown, cmd: Command) => { let secretKey: string - let source: 'flag' | 'env' | 'config' + let source: AuthSource + let keyName: string | null = null + let expiresAt: string | null = null const sourceLabels = { flag: 'inline flag (--api-key)', env: 'env var (FINTOC_API_KEY)', config: 'config file', - } satisfies Record + } satisfies Record const rootOpts = cmd.optsWithGlobals<{ apiKey?: string; json?: boolean }>() @@ -35,6 +38,8 @@ export const configCommand = (program: Command) => { const auth = resolveAuth(rootOpts) secretKey = auth.secretKey source = auth.source + keyName = auth.keyName ?? null + expiresAt = auth.expiresAt ?? null } catch { if (rootOpts.json) { printJson({ authenticated: false, config_path: CONFIG_PATH }) @@ -69,6 +74,8 @@ export const configCommand = (program: Command) => { organization: orgName, mode, secret_key: maskKey(secretKey), + key_name: keyName, + expires_at: expiresAt, api_version: apiVersion, config_path: CONFIG_PATH, source, @@ -80,6 +87,12 @@ export const configCommand = (program: Command) => { log(` Organization: ${orgName ?? '-'}`) log(` Mode: ${mode ?? '-'}`) log(` Secret key: ${maskKey(secretKey)}`) + if (keyName) { + log(` Key name: ${keyName}`) + } + if (expiresAt) { + log(` Expires at: ${expiresAt}`) + } log(` API version: ${apiVersion ?? '-'}`) log(` Config path: ${CONFIG_PATH}`) log(` Source: ${sourceLabels[source]}`) From 32fb930a8169a7272a8505ee6a36604a099e8563 Mon Sep 17 00:00:00 2001 From: FranciscaOtero Date: Tue, 2 Jun 2026 16:44:49 -0400 Subject: [PATCH 4/4] feat(login): wire browser flow with paste fallback and re-login confirmation --- src/commands/__tests__/login.test.ts | 374 ++++++++++++++++++++++++--- src/commands/login.ts | 220 +++++++++++++--- 2 files changed, 524 insertions(+), 70 deletions(-) diff --git a/src/commands/__tests__/login.test.ts b/src/commands/__tests__/login.test.ts index 850fbd7..41fb55e 100644 --- a/src/commands/__tests__/login.test.ts +++ b/src/commands/__tests__/login.test.ts @@ -1,16 +1,27 @@ -import { password } from '@inquirer/prompts' +import type { BrowserLoginResult, BrowserLoginSession } from '../../lib/browser-login.js' +import { confirm, password } from '@inquirer/prompts' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { whoami } from '../../lib/auth.js' +import { BrowserLoginError, startBrowserLogin } from '../../lib/browser-login.js' import { readConfig, writeConfig } from '../../lib/config.js' -import { error, success } from '../../lib/output.js' +import { error, hint, success, warn } from '../../lib/output.js' import { loginCommand } from '../login.js' vi.mock('../../lib/auth.js', () => ({ whoami: vi.fn(), - maskKey: vi.fn((k: string) => `${k.slice(0, 7)}····`), })) +vi.mock('../../lib/browser-login.js', async () => { + const actual = await vi.importActual( + '../../lib/browser-login.js', + ) + return { + ...actual, + startBrowserLogin: vi.fn(), + } +}) + vi.mock('../../lib/config.js', () => ({ readConfig: vi.fn(() => ({})), writeConfig: vi.fn(), @@ -20,15 +31,35 @@ vi.mock('../../lib/config.js', () => ({ vi.mock('../../lib/output.js', () => ({ log: vi.fn(), hint: vi.fn(), + info: vi.fn(), success: vi.fn(), error: vi.fn(), warn: vi.fn(), })) vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), password: vi.fn(), })) +const SESSION_URL = 'https://dashboard.fintoc.com/cli/authorize?state=stub' + +const never = (): Promise => new Promise(() => {}) + +const stubSession = (result: Promise): BrowserLoginSession => { + result.catch(() => {}) + return { url: SESSION_URL, result, cancel: vi.fn() } +} + +const mockBrowserCallback = (result: BrowserLoginResult) => + vi.mocked(startBrowserLogin).mockResolvedValue(stubSession(Promise.resolve(result))) + +const mockBrowserPending = () => + vi.mocked(startBrowserLogin).mockResolvedValue(stubSession(never())) + +const mockBrowserRejects = (err: unknown) => + vi.mocked(startBrowserLogin).mockResolvedValue(stubSession(Promise.reject(err))) + const createProgram = () => { const program = new Command() program.exitOverride() @@ -43,15 +74,59 @@ describe('login command', () => { beforeEach(() => { vi.clearAllMocks() process.stdin.isTTY = true + // Default: the inline paste prompt hangs so the callback wins the race. + vi.mocked(password).mockImplementation(() => never()) }) afterEach(() => { process.stdin.isTTY = originalIsTTY }) - describe('when authenticating interactively', () => { - test('prompts for key, validates via whoami, and saves config', async () => { - vi.mocked(password).mockResolvedValue('sk_test_valid123') + describe('when called without flags', () => { + test('runs browser flow in test mode and saves the result', async () => { + mockBrowserCallback({ + secret: 'sk_test_browser', + organizationName: 'Acme Corp', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(startBrowserLogin).toHaveBeenCalledWith(expect.objectContaining({ mode: 'test' })) + expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_browser' }) + expect(success).toHaveBeenCalledWith('Authenticated as Acme Corp (test mode)') + }) + }) + + describe('when called with --mode live', () => { + test('runs browser flow in live mode and stores key_name and expires_at', async () => { + mockBrowserCallback({ + secret: 'sk_live_browser', + organizationName: 'Acme Corp', + mode: 'live', + keyName: 'my-mac-cli', + expiresAt: '2026-08-16T12:00:00Z', + }) + + const program = createProgram() + await program.parseAsync(['login', '--mode', 'live'], { from: 'user' }) + + expect(startBrowserLogin).toHaveBeenCalledWith(expect.objectContaining({ mode: 'live' })) + expect(writeConfig).toHaveBeenCalledWith({ + secret_key: 'sk_live_browser', + key_name: 'my-mac-cli', + expires_at: '2026-08-16T12:00:00Z', + }) + expect(success).toHaveBeenCalledWith('Authenticated as Acme Corp (live mode)') + expect(hint).toHaveBeenCalledWith( + ` Key 'my-mac-cli' stored in /mock-home/.fintoc/config.toml`, + ) + }) + }) + + describe('when --api-key is passed with a valid prefix', () => { + test('skips the browser flow and uses the key directly', async () => { vi.mocked(whoami).mockResolvedValue({ organizationName: 'Acme Corp', mode: 'test', @@ -59,18 +134,127 @@ describe('login command', () => { }) const program = createProgram() - await program.parseAsync(['login'], { from: 'user' }) + await program.parseAsync(['login', '--api-key', 'sk_test_manual'], { from: 'user' }) - expect(password).toHaveBeenCalled() - expect(whoami).toHaveBeenCalledWith('sk_test_valid123') - expect(readConfig).toHaveBeenCalled() - expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_valid123' }) + expect(startBrowserLogin).not.toHaveBeenCalled() + expect(whoami).toHaveBeenCalledWith('sk_test_manual') + expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_manual' }) expect(success).toHaveBeenCalledWith('Authenticated as Acme Corp (test mode)') }) + }) - test('preserves existing config fields when writing', async () => { - vi.mocked(password).mockResolvedValue('sk_test_new') - vi.mocked(readConfig).mockReturnValue({ jws_private_key: '/path/to/key.pem' }) + describe('when --api-key has an invalid prefix', () => { + test('rejects locally without calling whoami', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect( + program.parseAsync(['login', '--api-key', 'invalid_key'], { from: 'user' }), + ).rejects.toThrow('process.exit') + + expect(whoami).not.toHaveBeenCalled() + expect(writeConfig).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('Invalid key format')) + + exitSpy.mockRestore() + }) + }) + + describe('when there is a saved key and the user does not confirm', () => { + test('aborts without calling the browser flow', async () => { + vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_existing' }) + vi.mocked(confirm).mockResolvedValue(false) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(confirm).toHaveBeenCalled() + expect(startBrowserLogin).not.toHaveBeenCalled() + expect(writeConfig).not.toHaveBeenCalled() + expect(hint).toHaveBeenCalledWith('Login aborted.') + }) + }) + + describe('when there is a saved key and the user confirms', () => { + test('proceeds to the browser flow', async () => { + vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_existing' }) + vi.mocked(confirm).mockResolvedValue(true) + mockBrowserCallback({ + secret: 'sk_test_new', + organizationName: 'Globex', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ default: true })) + expect(startBrowserLogin).toHaveBeenCalled() + expect(success).toHaveBeenCalledWith('Authenticated as Globex (test mode)') + }) + }) + + describe('when --yes is passed', () => { + test('skips the confirmation prompt', async () => { + vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_existing' }) + mockBrowserCallback({ + secret: 'sk_test_new', + organizationName: 'Acme', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login', '--yes'], { from: 'user' }) + + expect(confirm).not.toHaveBeenCalled() + expect(startBrowserLogin).toHaveBeenCalled() + }) + }) + + describe('when the browser flow is denied', () => { + test('exits with an error and hints', async () => { + mockBrowserRejects(new BrowserLoginError('denied', 'Authorization was denied in the browser')) + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') + + expect(error).toHaveBeenCalledWith('Authorization was denied in the browser') + expect(writeConfig).not.toHaveBeenCalled() + + exitSpy.mockRestore() + }) + }) + + describe('when the dashboard returns a mismatched mode', () => { + test('exits with a hint pointing at the right --mode flag', async () => { + mockBrowserRejects( + new BrowserLoginError('mismatch', "Dashboard returned mode 'live' but expected 'test'"), + ) + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') + + expect(hint).toHaveBeenCalledWith(expect.stringContaining('fintoc login --mode live')) + expect(writeConfig).not.toHaveBeenCalled() + + exitSpy.mockRestore() + }) + }) + + describe('when the user pastes the key while the callback is still pending', () => { + test('uses the pasted key and authenticates via whoami', async () => { + mockBrowserPending() + vi.mocked(password).mockResolvedValue('sk_test_pasted') vi.mocked(whoami).mockResolvedValue({ organizationName: 'Acme Corp', mode: 'test', @@ -80,58 +264,155 @@ describe('login command', () => { const program = createProgram() await program.parseAsync(['login'], { from: 'user' }) + expect(password).toHaveBeenCalled() + expect(whoami).toHaveBeenCalledWith('sk_test_pasted') + expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_pasted' }) + expect(success).toHaveBeenCalledWith('Authenticated as Acme Corp (test mode)') + }) + + test('aborts cleanly when the user cancels with Ctrl+C', async () => { + mockBrowserPending() + const exitError = new Error('User cancelled') + exitError.name = 'ExitPromptError' + vi.mocked(password).mockRejectedValue(exitError) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(password).toHaveBeenCalled() + expect(hint).toHaveBeenCalledWith('Login aborted.') + expect(writeConfig).not.toHaveBeenCalled() + }) + }) + + describe('when the browser flow times out', () => { + test('exits with an error and hints', async () => { + mockBrowserRejects( + new BrowserLoginError('timeout', 'Login timed out waiting for authorization.'), + ) + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') + + expect(error).toHaveBeenCalledWith(expect.stringContaining('timed out')) + expect(writeConfig).not.toHaveBeenCalled() + + exitSpy.mockRestore() + }) + }) + + describe('when re-logging in with a previously stored jws_private_key', () => { + test('preserves jws_private_key and warns that it may not apply', async () => { + vi.mocked(readConfig).mockReturnValue({ + secret_key: 'sk_test_old', + jws_private_key: '/path/to/key.pem', + }) + vi.mocked(confirm).mockResolvedValue(true) + mockBrowserCallback({ + secret: 'sk_test_new', + organizationName: 'Globex', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + expect(writeConfig).toHaveBeenCalledWith({ + secret_key: 'sk_test_new', jws_private_key: '/path/to/key.pem', + }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('jws_private_key')) + }) + + test('does not warn when no jws_private_key was stored', async () => { + vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_old' }) + vi.mocked(confirm).mockResolvedValue(true) + mockBrowserCallback({ + secret: 'sk_test_new', + organizationName: 'Globex', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(warn).not.toHaveBeenCalled() + }) + + test('does not warn on first login (no previous secret_key)', async () => { + vi.mocked(readConfig).mockReturnValue({ jws_private_key: '/path/to/key.pem' }) + mockBrowserCallback({ + secret: 'sk_test_new', + organizationName: 'Acme Corp', + mode: 'test', + }) + + const program = createProgram() + await program.parseAsync(['login'], { from: 'user' }) + + expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_new', + jws_private_key: '/path/to/key.pem', }) + expect(warn).not.toHaveBeenCalled() }) }) - describe('when key validation fails', () => { - test('exits with error and does not save config', async () => { - vi.mocked(password).mockResolvedValue('sk_test_invalid') - vi.mocked(whoami).mockRejectedValue(new Error('Invalid API key')) + describe('when writing the config fails after a successful auth', () => { + test('exits with an error pointing at the config path', async () => { + mockBrowserCallback({ + secret: 'sk_test_browser', + organizationName: 'Acme Corp', + mode: 'test', + }) + vi.mocked(writeConfig).mockImplementationOnce(() => { + throw new Error('EACCES') + }) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit') }) const program = createProgram() - await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') - expect(writeConfig).not.toHaveBeenCalled() - expect(error).toHaveBeenCalledWith('Invalid API key') + expect(error).toHaveBeenCalledWith(expect.stringContaining('could not write')) + expect(success).not.toHaveBeenCalled() exitSpy.mockRestore() }) }) - describe('when running in non-TTY environment', () => { + describe('when running in non-TTY without --api-key', () => { beforeEach(() => { - process.stdin.isTTY = false as unknown as boolean + process.stdin.isTTY = false }) - test('exits with error when --api-key is not provided', async () => { + test('exits early with a hint to use --api-key', async () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit') }) const program = createProgram() - await expect(program.parseAsync(['login'], { from: 'user' })).rejects.toThrow('process.exit') - expect(error).toHaveBeenCalledWith( - 'Non-interactive terminal detected. Pass the key via flag:', - ) + expect(startBrowserLogin).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('Non-interactive terminal')) exitSpy.mockRestore() }) }) - describe('when using --api-key flag', () => { - test('skips prompt and authenticates directly', async () => { - vi.mocked(readConfig).mockReturnValue({}) + describe('when running in non-TTY with --api-key', () => { + beforeEach(() => { + process.stdin.isTTY = false + }) + + test('authenticates with the inline key', async () => { vi.mocked(whoami).mockResolvedValue({ organizationName: 'Acme Corp', mode: 'test', @@ -139,12 +420,35 @@ describe('login command', () => { }) const program = createProgram() - await program.parseAsync(['login', '--api-key', 'sk_test_flag123'], { from: 'user' }) + await program.parseAsync(['login', '--api-key', 'sk_test_inline'], { from: 'user' }) - expect(password).not.toHaveBeenCalled() - expect(whoami).toHaveBeenCalledWith('sk_test_flag123') - expect(writeConfig).toHaveBeenCalledWith({ secret_key: 'sk_test_flag123' }) + expect(whoami).toHaveBeenCalledWith('sk_test_inline') expect(success).toHaveBeenCalledWith('Authenticated as Acme Corp (test mode)') }) }) + + describe('when running in non-TTY with --api-key, an existing session, and no --yes', () => { + beforeEach(() => { + process.stdin.isTTY = false + }) + + test('exits with an error asking for --yes', async () => { + vi.mocked(readConfig).mockReturnValue({ secret_key: 'sk_test_existing' }) + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect( + program.parseAsync(['login', '--api-key', 'sk_test_new'], { from: 'user' }), + ).rejects.toThrow('process.exit') + + expect(whoami).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining('Non-interactive terminal')) + expect(hint).toHaveBeenCalledWith(expect.stringContaining('Re-run with --yes to override.')) + + exitSpy.mockRestore() + }) + }) }) diff --git a/src/commands/login.ts b/src/commands/login.ts index 4dca91f..e55be58 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,49 +1,199 @@ import type { Command } from 'commander' -import { password } from '@inquirer/prompts' +import type { + BrowserLoginMode, + BrowserLoginResult, + BrowserLoginSession, +} from '../lib/browser-login.js' +import type { FintocConfig } from '../types.js' + +import { confirm, password } from '@inquirer/prompts' +import { Option } from 'commander' import { whoami } from '../lib/auth.js' +import { BrowserLoginError, startBrowserLogin } from '../lib/browser-login.js' import { CONFIG_PATH, readConfig, writeConfig } from '../lib/config.js' +import { BROWSER_LOGIN_TIMEOUT_MS } from '../lib/constants.js' import { handleError } from '../lib/errors.js' -import { error, hint, success } from '../lib/output.js' +import { error, hint, info, success, warn } from '../lib/output.js' -export const loginCommand = (program: Command) => { - const cmd = program.command('login').description('Authenticate with your Fintoc API key') - cmd.configureHelp({ showGlobalOptions: true }) - cmd.action(async (_opts: unknown, actionCmd: Command) => { - let secretKey: string - - const rootApiKey = actionCmd.parent?.opts().apiKey as string | undefined - if (rootApiKey) { - secretKey = rootApiKey - } else if (process.stdin.isTTY) { - secretKey = await password({ message: 'Enter your Fintoc secret key:' }) - } else { - error('Non-interactive terminal detected. Pass the key via flag:') - hint('') - hint(' fintoc login --api-key sk_test_...') - process.exit(1) - } +type LoginOpts = { mode: BrowserLoginMode; yes?: boolean } +type RootOpts = { apiKey?: string } + +const SECRET_KEY_PATTERN = /^sk_(?:test|live)_/ - if (!secretKey) { - error('No key provided') - process.exit(1) +const isPromptCancelled = (err: unknown): boolean => + err instanceof Error && err.name === 'ExitPromptError' + +const persistAndAnnounce = (existingConfig: FintocConfig, outcome: BrowserLoginResult) => { + const next: FintocConfig = { + ...existingConfig, + secret_key: outcome.secret, + key_name: outcome.keyName, + expires_at: outcome.expiresAt, + } + + try { + writeConfig(next) + } catch { + error(`Authentication succeeded but could not write to ${CONFIG_PATH}`) + process.exit(1) + } + + success(`Authenticated as ${outcome.organizationName} (${outcome.mode} mode)`) + hint( + outcome.keyName + ? ` Key '${outcome.keyName}' stored in ${CONFIG_PATH}` + : ` Key stored in ${CONFIG_PATH}`, + ) + + const isRelogin = !!existingConfig.secret_key + if (isRelogin && existingConfig.jws_private_key) { + warn(` jws_private_key is still set but may not apply to this org/mode`) + hint(` Update it with: fintoc config set jws_private_key `) + } +} + +const saveSecret = async (existingConfig: FintocConfig, secretKey: string) => { + if (!SECRET_KEY_PATTERN.test(secretKey)) { + error(`Invalid key format. Expected 'sk_test_...' or 'sk_live_...'.`) + process.exit(1) + } + try { + const { organizationName, mode } = await whoami(secretKey) + persistAndAnnounce(existingConfig, { secret: secretKey, organizationName, mode }) + } catch (err) { + handleError(err) + } +} + +type ConfirmReloginOpts = { config: FintocConfig; skipPrompt: boolean } + +const confirmRelogin = async ({ config, skipPrompt }: ConfirmReloginOpts): Promise => { + if (skipPrompt || !config.secret_key) { + return true + } + + const mode = config.secret_key.startsWith('sk_live_') ? 'live' : 'test' + const keySuffix = config.key_name ? ` (key '${config.key_name}')` : '' + + if (!process.stdin.isTTY) { + error(`Non-interactive terminal detected. Cannot prompt to override existing session.`) + hint(`\n Existing session: ${mode} mode${keySuffix}.`) + hint(` Re-run with --yes to override.`) + process.exit(1) + } + + const proceed = await confirm({ + message: `Already authenticated in ${mode} mode${keySuffix}. Continue?`, + default: true, + }) + if (!proceed) { + hint('Login aborted.') + } + return proceed +} + +const promptPaste = (signal: AbortSignal) => + password( + { + message: 'Or paste your secret key:', + mask: '•', + validate: (v) => + SECRET_KEY_PATTERN.test(v.trim()) || `Expected 'sk_test_...' or 'sk_live_...'`, + }, + { signal }, + ).then((s) => s.trim()) + +const handleBrowserError = (err: BrowserLoginError, mode: BrowserLoginMode): never => { + error(err.message) + switch (err.reason) { + case 'mismatch': { + const otherMode = mode === 'test' ? 'live' : 'test' + hint(`\n Re-run with the right mode: fintoc login --mode ${otherMode}`) + break } + case 'denied': + case 'timeout': + hint('\n Run again: fintoc login') + hint(' Or paste inline: fintoc login --api-key sk_...') + break + default: + err.reason satisfies never + } + process.exit(1) +} - try { - const info = await whoami(secretKey) +const runBrowserFlow = async (existingConfig: FintocConfig, mode: BrowserLoginMode) => { + const promptAc = new AbortController() + let session: BrowserLoginSession | undefined + try { + session = await startBrowserLogin({ mode }) - try { - writeConfig({ ...readConfig(), secret_key: secretKey }) - } catch { - error(`Key is valid but could not write to ${CONFIG_PATH}`) - process.exit(1) - } + info('Opening browser to authenticate...') + hint(`\n If the browser doesn't open, visit:\n ${session.url}`) + hint(` Waiting for authorization (${BROWSER_LOGIN_TIMEOUT_MS / 60_000} min timeout).\n`) - success(`Authenticated as ${info.organizationName} (${info.mode} mode)`) - hint(` Key stored in ${CONFIG_PATH}`) - } catch (err) { - handleError(err) + const callback = session.result.then((result) => ({ kind: 'callback' as const, result })) + const paste = promptPaste(promptAc.signal).then((secretKey) => ({ + kind: 'paste' as const, + secretKey, + })) + + const winner = await Promise.race([callback, paste]) + if (winner.kind === 'callback') { + persistAndAnnounce(existingConfig, winner.result) + } else { + await saveSecret(existingConfig, winner.secretKey) } - }) + } catch (err) { + if (err instanceof BrowserLoginError) { + handleBrowserError(err, mode) + } else { + throw err + } + } finally { + promptAc.abort() + session?.cancel() + } +} + +export const loginCommand = (program: Command) => { + program + .command('login') + .description('Authenticate with your Fintoc API key') + .addOption( + new Option('--mode ', 'Authorization mode').choices(['test', 'live']).default('test'), + ) + .option('-y, --yes', 'Skip confirmation when overriding an existing session') + .configureHelp({ showGlobalOptions: true }) + .action(async (opts: LoginOpts, actionCmd: Command) => { + try { + const { apiKey } = actionCmd.optsWithGlobals() + + if (!process.stdin.isTTY && !apiKey) { + error('Non-interactive terminal detected.') + hint('\n Pass the key via flag: fintoc login --api-key sk_...') + hint(' Or set env: export FINTOC_API_KEY=sk_...') + process.exit(1) + } + + const existingConfig = readConfig() + if (!(await confirmRelogin({ config: existingConfig, skipPrompt: !!opts.yes }))) { + return + } + + if (apiKey) { + await saveSecret(existingConfig, apiKey) + return + } + await runBrowserFlow(existingConfig, opts.mode) + } catch (err) { + if (isPromptCancelled(err)) { + hint('Login aborted.') + return + } + handleError(err) + } + }) }