diff --git a/src/commands/__tests__/login.test.ts b/src/commands/__tests__/login.test.ts index 41fb55e..3fe1c8e 100644 --- a/src/commands/__tests__/login.test.ts +++ b/src/commands/__tests__/login.test.ts @@ -285,6 +285,47 @@ describe('login command', () => { }) }) + describe('when the pasted key prefix does not match --mode', () => { + test('rejects with mismatch and hints at the right --mode', async () => { + mockBrowserPending() + vi.mocked(password).mockResolvedValue('sk_live_pasted') + + 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(whoami).not.toHaveBeenCalled() + expect(writeConfig).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining("'live'")) + expect(hint).toHaveBeenCalledWith(expect.stringContaining('fintoc login --mode live')) + + exitSpy.mockRestore() + }) + + test('rejects sk_test_ paste when --mode live was requested', async () => { + mockBrowserPending() + vi.mocked(password).mockResolvedValue('sk_test_pasted') + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit') + }) + + const program = createProgram() + await expect( + program.parseAsync(['login', '--mode', 'live'], { from: 'user' }), + ).rejects.toThrow('process.exit') + + expect(whoami).not.toHaveBeenCalled() + expect(writeConfig).not.toHaveBeenCalled() + expect(hint).toHaveBeenCalledWith(expect.stringContaining('fintoc login --mode test')) + + exitSpy.mockRestore() + }) + }) + describe('when the browser flow times out', () => { test('exits with an error and hints', async () => { mockBrowserRejects( diff --git a/src/commands/login.ts b/src/commands/login.ts index e55be58..a213359 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,11 +1,7 @@ import type { Command } from 'commander' -import type { - BrowserLoginMode, - BrowserLoginResult, - BrowserLoginSession, -} from '../lib/browser-login.js' -import type { FintocConfig } from '../types.js' +import type { BrowserLoginResult, BrowserLoginSession } from '../lib/browser-login.js' +import type { FintocConfig, FintocMode } from '../types.js' import { confirm, password } from '@inquirer/prompts' import { Option } from 'commander' @@ -17,10 +13,18 @@ import { BROWSER_LOGIN_TIMEOUT_MS } from '../lib/constants.js' import { handleError } from '../lib/errors.js' import { error, hint, info, success, warn } from '../lib/output.js' -type LoginOpts = { mode: BrowserLoginMode; yes?: boolean } +type LoginOpts = { mode: FintocMode; yes?: boolean } type RootOpts = { apiKey?: string } -const SECRET_KEY_PATTERN = /^sk_(?:test|live)_/ +const modeFromSecret = (secret: string): FintocMode | null => { + if (secret.startsWith('sk_live_')) { + return 'live' + } + if (secret.startsWith('sk_test_')) { + return 'test' + } + return null +} const isPromptCancelled = (err: unknown): boolean => err instanceof Error && err.name === 'ExitPromptError' @@ -55,7 +59,7 @@ const persistAndAnnounce = (existingConfig: FintocConfig, outcome: BrowserLoginR } const saveSecret = async (existingConfig: FintocConfig, secretKey: string) => { - if (!SECRET_KEY_PATTERN.test(secretKey)) { + if (!modeFromSecret(secretKey)) { error(`Invalid key format. Expected 'sk_test_...' or 'sk_live_...'.`) process.exit(1) } @@ -74,7 +78,7 @@ const confirmRelogin = async ({ config, skipPrompt }: ConfirmReloginOpts): Promi return true } - const mode = config.secret_key.startsWith('sk_live_') ? 'live' : 'test' + const mode = modeFromSecret(config.secret_key) ?? 'test' const keySuffix = config.key_name ? ` (key '${config.key_name}')` : '' if (!process.stdin.isTTY) { @@ -100,12 +104,12 @@ const promptPaste = (signal: AbortSignal) => message: 'Or paste your secret key:', mask: '•', validate: (v) => - SECRET_KEY_PATTERN.test(v.trim()) || `Expected 'sk_test_...' or 'sk_live_...'`, + modeFromSecret(v.trim()) !== null || `Expected 'sk_test_...' or 'sk_live_...'`, }, { signal }, ).then((s) => s.trim()) -const handleBrowserError = (err: BrowserLoginError, mode: BrowserLoginMode): never => { +const handleBrowserError = (err: BrowserLoginError, mode: FintocMode): never => { error(err.message) switch (err.reason) { case 'mismatch': { @@ -124,7 +128,7 @@ const handleBrowserError = (err: BrowserLoginError, mode: BrowserLoginMode): nev process.exit(1) } -const runBrowserFlow = async (existingConfig: FintocConfig, mode: BrowserLoginMode) => { +const runBrowserFlow = async (existingConfig: FintocConfig, mode: FintocMode) => { const promptAc = new AbortController() let session: BrowserLoginSession | undefined try { @@ -144,6 +148,13 @@ const runBrowserFlow = async (existingConfig: FintocConfig, mode: BrowserLoginMo if (winner.kind === 'callback') { persistAndAnnounce(existingConfig, winner.result) } else { + const pastedMode = modeFromSecret(winner.secretKey) + if (pastedMode !== mode) { + throw new BrowserLoginError( + 'mismatch', + `Pasted key looks like '${pastedMode}' but expected '${mode}'`, + ) + } await saveSecret(existingConfig, winner.secretKey) } } catch (err) { diff --git a/src/lib/browser-login.ts b/src/lib/browser-login.ts index 812d45c..910adc7 100644 --- a/src/lib/browser-login.ts +++ b/src/lib/browser-login.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' +import type { FintocMode } from '../types.js' import { Buffer } from 'node:buffer' import { randomBytes, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' @@ -13,18 +14,16 @@ import { DASHBOARD_ORIGIN, } from './constants.js' -export type BrowserLoginMode = 'test' | 'live' - export type BrowserLoginResult = { secret: string organizationName: string - mode: BrowserLoginMode + mode: FintocMode keyName?: string expiresAt?: string } export type BrowserLoginOptions = { - mode: BrowserLoginMode + mode: FintocMode timeoutMs?: number } @@ -65,7 +64,7 @@ export const buildAuthorizeUrl = ({ callback, suggestedName, }: { - mode: BrowserLoginMode + mode: FintocMode state: string callback: string suggestedName: string @@ -137,7 +136,7 @@ const callbackPayloadSchema = z.union([deniedPayloadSchema, successPayloadSchema export const parseCallback = ( body: unknown, expectedState: string, - expectedMode: BrowserLoginMode, + expectedMode: FintocMode, ): CallbackOutcome => { const parsed = callbackPayloadSchema.safeParse(body) if (!parsed.success) { diff --git a/src/types.ts b/src/types.ts index ab3deef..89acb04 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +export type FintocMode = 'test' | 'live' + export type FintocConfig = { secret_key?: string jws_private_key?: string