diff --git a/src/bin.ts b/src/bin.ts index 3b8f8cda..4b8db6dc 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -62,6 +62,9 @@ import { ExitCode } from './utils/exit-codes.js'; import { analytics } from './utils/analytics.js'; import { formatWorkOSCommand, getWorkOSCommand } from './utils/command-invocation.js'; import { MIGRATIONS_DESCRIPTION } from './lib/constants.js'; +// Type-only import (erased at build, does not pull the handler into the startup +// path) so the `argv.method as VerifyLoginMethod` cast type-checks below. +import type { VerifyLoginMethod } from './commands/verify-login.js'; // Enable debug logging for all commands via env var. // Subsumes the installer's --debug flag for non-installer commands. @@ -594,6 +597,47 @@ async function runCli(): Promise { await handleDoctor(argv); }, ) + .command( + 'verify-login', + 'Verify the AuthKit login loop end-to-end against the active environment (creates and deletes a throwaway user)', + (yargs) => + yargs.options({ + ...insecureStorageOption, + 'api-key': { + type: 'string' as const, + describe: + 'WorkOS API key (overrides environment config). Format: sk_test_* (production keys are refused)', + }, + 'client-id': { + type: 'string' as const, + describe: 'WorkOS client ID (overrides the active environment)', + }, + method: { + type: 'string' as const, + choices: ['password'] as const, + default: 'password', + describe: 'Authentication method to verify', + }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { getActiveEnvironment } = await import('./lib/config-store.js'); + const { runVerifyLogin } = await import('./commands/verify-login.js'); + + const apiKey = resolveApiKey({ apiKey: argv.apiKey as string | undefined }); // exits 4 if none + const activeEnv = getActiveEnvironment(); + + await runVerifyLogin({ + apiKey, + clientId: (argv.clientId as string | undefined) ?? activeEnv?.clientId, + baseUrl: resolveApiBaseUrl(), + envType: activeEnv?.type ?? null, + envName: activeEnv?.name, + method: argv.method as VerifyLoginMethod, + }); + }, + ) // NOTE: When adding commands here, also update src/utils/help-json.ts .command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => { yargs.options(insecureStorageOption); diff --git a/src/commands/verify-login.spec.ts b/src/commands/verify-login.spec.ts new file mode 100644 index 00000000..7b659ec2 --- /dev/null +++ b/src/commands/verify-login.spec.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// Type-only import is erased at build time, so it does not load the module +// before vi.mock applies (the runtime handler is loaded via dynamic import below). +import type { VerifyLoginOptions } from './verify-login.js'; + +const mockSdk = { + userManagement: { + createUser: vi.fn(), + authenticateWithPassword: vi.fn(), + authenticateWithMagicAuth: vi.fn(), + createMagicAuth: vi.fn(), + deleteUser: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runVerifyLogin } = await import('./verify-login.js'); + +function baseOptions(overrides: Partial = {}): VerifyLoginOptions { + return { + apiKey: 'sk_test_x', + clientId: 'client_x', + baseUrl: 'https://api.workos.com', + envType: 'sandbox', + envName: 'sandbox', + method: 'password', + ...overrides, + }; +} + +/** Run the handler and return the thrown CliExit (fails the test if none thrown). */ +async function expectCliExit(options: VerifyLoginOptions): Promise> { + try { + await runVerifyLogin(options); + } catch (err) { + expect(err).toBeInstanceOf(CliExit); + return err as InstanceType; + } + throw new Error('Expected runVerifyLogin to throw CliExit, but it resolved'); +} + +describe('verify-login', () => { + let consoleOutput: string[]; + let errorOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + errorOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errorOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('smoke: exports runVerifyLogin', () => { + expect(typeof runVerifyLogin).toBe('function'); + }); + + it('happy path: create → authenticate → assert tokens → cleanup, in order', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + await runVerifyLogin(baseOptions()); + + expect(mockSdk.userManagement.createUser).toHaveBeenCalledTimes(1); + const createArgs = mockSdk.userManagement.createUser.mock.calls[0][0]; + expect(createArgs).toEqual(expect.objectContaining({ emailVerified: true })); + expect(createArgs.email).toMatch(/^verify-login\+.*@example\.workos\.dev$/); + expect(typeof createArgs.password).toBe('string'); + expect(createArgs.password.length).toBeGreaterThan(0); + + expect(mockSdk.userManagement.authenticateWithPassword).toHaveBeenCalledTimes(1); + const authArgs = mockSdk.userManagement.authenticateWithPassword.mock.calls[0][0]; + expect(authArgs).toEqual(expect.objectContaining({ clientId: 'client_x' })); + // Same throwaway credentials must be reused for the authenticate step. + expect(authArgs.email).toBe(createArgs.email); + expect(authArgs.password).toBe(createArgs.password); + + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledTimes(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + + expect(consoleOutput.some((l) => /passed/i.test(l))).toBe(true); + }); + + it('cleanup-on-failure: authenticate rejects but deleteUser still runs; exit 1', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockRejectedValue(new Error('invalid credentials')); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + }); + + it('createUser throws: no orphan, verdict false, no deleteUser call; exit 1', async () => { + mockSdk.userManagement.createUser.mockRejectedValue(new Error('boom')); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('orphaned user: cleanup fails after a passing verification; resolves (exit 0)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockRejectedValue(new Error('network')); + + // Verification passed → a leaked user does not fail the login verdict. + await expect(runVerifyLogin(baseOptions())).resolves.toBeUndefined(); + + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + expect(consoleOutput.some((l) => l.includes('user_x'))).toBe(true); + }); + + it('production refusal (env.type): NO SDK calls, exit 1', async () => { + const exit = await expectCliExit(baseOptions({ envType: 'production' })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('production refusal (sk_live_ key): NO SDK calls, exit 1', async () => { + const exit = await expectCliExit(baseOptions({ envType: null, apiKey: 'sk_live_x' })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.authenticateWithPassword).not.toHaveBeenCalled(); + expect(mockSdk.userManagement.deleteUser).not.toHaveBeenCalled(); + }); + + it('missing clientId: structured error, NO SDK calls', async () => { + const exit = await expectCliExit(baseOptions({ clientId: undefined })); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + // Human mode: the error message references configuring a client id. + expect(errorOutput.some((l) => /client id/i.test(l))).toBe(true); + }); + + it('tokens missing: verdict false, deleteUser still called; exit 1', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: '', + refreshToken: '', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + + expect(exit.exitCode).toBe(1); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_x'); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('verdict shape (happy path)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockResolvedValue({ + accessToken: 'a', + refreshToken: 'r', + user: {}, + }); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + await runVerifyLogin(baseOptions()); + + const output = JSON.parse(consoleOutput[0]); + expect(output.success).toBe(true); + expect(output.method).toBe('password'); + expect(output.environment).toBe('sandbox'); + expect(Array.isArray(output.checks)).toBe(true); + expect(output.checks).toHaveLength(3); + for (const check of output.checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('passed'); + expect(check).toHaveProperty('detail'); + } + expect(output.userId).toBe('user_x'); + expect(output.userCleanedUp).toBe(true); + expect(output.orphanedUserId).toBeNull(); + }); + + it('verdict shape (failed auth)', async () => { + mockSdk.userManagement.createUser.mockResolvedValue({ id: 'user_x' }); + mockSdk.userManagement.authenticateWithPassword.mockRejectedValue(new Error('invalid credentials')); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); + + const exit = await expectCliExit(baseOptions()); + expect(exit.exitCode).toBe(1); + + const output = JSON.parse(consoleOutput[0]); + expect(output.success).toBe(false); + expect(output.userCleanedUp).toBe(true); + const authCheck = output.checks.find((c: { name: string }) => c.name === 'authenticate'); + expect(authCheck.passed).toBe(false); + }); + + it('production refusal emits error envelope with production_env_refused code', async () => { + const exit = await expectCliExit(baseOptions({ envType: 'production' })); + expect(exit.exitCode).toBe(1); + expect(JSON.parse(errorOutput[0]).error.code).toBe('production_env_refused'); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + }); + + it('missing clientId emits error envelope with missing_client_id code', async () => { + const exit = await expectCliExit(baseOptions({ clientId: undefined })); + expect(exit.exitCode).toBe(1); + expect(JSON.parse(errorOutput[0]).error.code).toBe('missing_client_id'); + expect(mockSdk.userManagement.createUser).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/verify-login.ts b/src/commands/verify-login.ts new file mode 100644 index 00000000..9526a64d --- /dev/null +++ b/src/commands/verify-login.ts @@ -0,0 +1,212 @@ +/** + * verify-login: prove the AuthKit login loop end-to-end against the active + * environment, headlessly (no browser, no email/OTP retrieval). + * + * The command creates a throwaway user, authenticates it with the password + * grant, asserts access + refresh tokens come back, and deletes the user in a + * `finally` block so cleanup always runs. It refuses to touch a production + * environment (unconditional, no override) because it mutates real users. + */ + +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { exitWithCode, ExitCode } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +export type VerifyLoginMethod = 'password' | 'magic-auth'; + +export interface VerifyLoginOptions { + /** Already resolved by bin.ts via resolveApiKey (exits 4 if none). */ + apiKey: string; + /** --client-id ?? activeEnv.clientId. */ + clientId?: string; + /** resolveApiBaseUrl(). */ + baseUrl?: string; + /** activeEnv.type — drives the production refusal. */ + envType?: 'production' | 'sandbox' | 'unclaimed' | null; + /** activeEnv.name — display only. */ + envName?: string; + /** Authentication method to verify (default 'password'). */ + method?: VerifyLoginMethod; +} + +interface Check { + name: 'create_user' | 'authenticate' | 'tokens'; + passed: boolean; + detail: string; +} + +interface VerifyLoginResult { + success: boolean; + method: VerifyLoginMethod; + environment: string | null; + checks: Check[]; + userId: string | null; + userCleanedUp: boolean; + orphanedUserId: string | null; +} + +/** + * A clearly-labeled, non-routable throwaway email so the user is obvious in the + * dashboard if cleanup ever fails. + */ +function generateEmail(): string { + return `verify-login+${crypto.randomUUID()}@example.workos.dev`; +} + +/** + * A strong random password (>= 24 chars) mixing character classes so it + * satisfies the API password policy regardless of the specific rules. The + * fixed `Aa1!` suffix guarantees upper/lower/digit/symbol coverage on top of + * the random base64url body. + */ +function generatePassword(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + const body = Buffer.from(bytes).toString('base64url'); + return `${body}Aa1!`; +} + +/** + * Translate an authenticate error into a human-readable check detail. When the + * failure looks like "password authentication not enabled" (a config gap, not a + * broken login) surface an actionable message instead of a bare error so agents + * do not misread it as "login is broken". + * + * NOTE: the exact code/message returned when password auth is disabled is + * UNCONFIRMED against a live environment; the heuristic below is best-effort and + * falls back to the raw error message. + */ +function describeAuthError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (/password/i.test(message) && /(not enabled|disabled|not available|unavailable)/i.test(message)) { + return ( + 'Password authentication is not enabled for this environment. Enable it in the WorkOS ' + + 'Dashboard (User Management → Authentication).' + ); + } + return message; +} + +function printHuman(result: VerifyLoginResult): void { + for (const check of result.checks) { + const mark = check.passed ? chalk.green('✓') : chalk.red('✗'); + console.log(`${mark} ${check.detail}`); + } + + if (result.userId) { + if (result.userCleanedUp) { + console.log(`${chalk.green('✓')} Cleaned up test user`); + } else { + console.log( + chalk.yellow( + `⚠ Could not delete test user ${result.orphanedUserId} — delete it manually in the dashboard.`, + ), + ); + } + } + + const envLabel = result.environment ?? 'the active environment'; + if (result.success) { + console.log(chalk.green(`Login verification passed for ${envLabel}.`)); + } else { + console.log(chalk.red(`Login verification FAILED for ${envLabel}.`)); + } +} + +export async function runVerifyLogin(options: VerifyLoginOptions): Promise { + const method: VerifyLoginMethod = options.method ?? 'password'; + const environment = options.envName ?? null; + + // 1. Production refusal — before any SDK call. verify-login mutates real + // users, so it must never touch production. Refuse on either the stored env + // type OR a production-format key (belt-and-suspenders for keys passed via + // --api-key / WORKOS_API_KEY with no matching stored env). + if (options.envType === 'production' || options.apiKey.startsWith('sk_live_')) { + exitWithError({ + code: 'production_env_refused', + message: + 'verify-login creates and deletes real users and will not run against a production ' + + 'environment. Switch to a staging/sandbox environment first.', + }); + } + + // 2. clientId check — required for the password grant. + const clientId = options.clientId; + if (!clientId) { + exitWithError({ + code: 'missing_client_id', + message: + `No client ID for the active environment. Pass --client-id, or configure one via ` + + `\`${formatWorkOSCommand('env add --client-id ')}\`.`, + }); + } + + // 3. Construct the client. + const { sdk } = createWorkOSClient(options.apiKey, options.baseUrl); + + // 4. Generate throwaway credentials. + const email = generateEmail(); + const password = generatePassword(); + + // 5. Run the round-trip. Cleanup runs in `finally` so no orphaned user + // remains on the happy path or on an authentication failure. + const checks: Check[] = []; + let userId: string | null = null; + let userCleanedUp = true; + let orphanedUserId: string | null = null; + + try { + const user = await sdk.userManagement.createUser({ email, password, emailVerified: true }); + userId = user.id; + checks.push({ name: 'create_user', passed: true, detail: `Created test user ${user.id}` }); + + const auth = await sdk.userManagement.authenticateWithPassword({ clientId, email, password }); + checks.push({ name: 'authenticate', passed: true, detail: 'password grant succeeded' }); + + const hasTokens = Boolean(auth.accessToken && auth.refreshToken); + checks.push({ + name: 'tokens', + passed: hasTokens, + detail: hasTokens ? 'access + refresh tokens present' : 'tokens missing from response', + }); + } catch (err) { + const failedStep: Check['name'] = userId ? 'authenticate' : 'create_user'; + checks.push({ name: failedStep, passed: false, detail: describeAuthError(err) }); + } finally { + if (userId) { + try { + await sdk.userManagement.deleteUser(userId); + } catch { + userCleanedUp = false; + orphanedUserId = userId; + } + } + } + + const success = checks.length > 0 && checks.every((c) => c.passed); + + const result: VerifyLoginResult = { + success, + method, + environment, + checks, + userId, + userCleanedUp, + orphanedUserId, + }; + + if (isJsonMode()) { + outputJson(result); + } else { + printHuman(result); + } + + // Exit follows the login verdict, not cleanup: a leaked user is surfaced + // machine-readably but does not fail the verification. No error arg here — the + // verdict was already emitted, so a second envelope would confuse parsers. + if (!success) { + exitWithCode(ExitCode.GENERAL_ERROR); + } +} diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 8cf87c0c..2b099ab0 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -57,6 +57,7 @@ describe('help-json', () => { 'auth status', 'skills', 'doctor', + 'verify-login', 'env', 'organization', 'user', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 8b728ada..754dbfab 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -284,6 +284,31 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'verify-login', + description: + 'Verify the AuthKit login loop end-to-end against the active environment (creates and deletes a throwaway user)', + options: [ + insecureStorageOpt, + apiKeyOpt, + { + name: 'client-id', + type: 'string', + description: 'WorkOS client ID (overrides the active environment)', + required: false, + hidden: false, + }, + { + name: 'method', + type: 'string', + description: 'Authentication method to verify', + required: false, + default: 'password', + choices: ['password'], + hidden: false, + }, + ], + }, { name: 'env', description: 'Manage environment configurations (API keys, endpoints, active environment)',