Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/commands/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
37 changes: 24 additions & 13 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand All @@ -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': {
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
11 changes: 5 additions & 6 deletions src/lib/browser-login.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
}

Expand Down Expand Up @@ -65,7 +64,7 @@ export const buildAuthorizeUrl = ({
callback,
suggestedName,
}: {
mode: BrowserLoginMode
mode: FintocMode
state: string
callback: string
suggestedName: string
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type FintocMode = 'test' | 'live'

export type FintocConfig = {
secret_key?: string
jws_private_key?: string
Expand Down
Loading