From 738d8cccd8acbec0495194a5cb4b2d5a47e6cee3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 18:28:22 -0500 Subject: [PATCH] feat: add verify-login command for headless AuthKit loop verification Add `workos verify-login`, a top-level resource command that proves the AuthKit login loop end-to-end against the active environment with no browser and no email/OTP retrieval, closing the last agent-oriented gap in the friction log (an autonomous agent could not self-verify the post-login flow). The command runs a full round-trip against the bundled @workos-inc/node SDK: createUser (throwaway, emailVerified) -> authenticateWithPassword -> assert access + refresh tokens -> deleteUser in a finally block so cleanup always runs, even on auth failure. A failed cleanup is surfaced machine-readably (orphanedUserId + userCleanedUp:false) rather than swallowed, and does not change the exit code. Two unconditional safety rules (no override flag): production refusal on both env.type === 'production' and an sk_live_ key prefix, before any SDK call; and always-attempt cleanup. Output has human (checklist + verdict) and JSON (flat verdict object) modes driven by the global --json flag and non-TTY auto-detection. Auth-required exits 4 via resolveApiKey; refusal / missing client id emit the standard error envelope and exit 1; a failed verification emits the verdict with success:false and exits 1. The --method magic-auth stretch is not shipped: its gate (empirical staging confirmation that createMagicAuth returns the code in the API response) cannot be exercised in this headless run, so per the spec the flag is dropped and password grant remains the only shipped method. Resolves friction-log goal 10. --- src/bin.ts | 44 ++++++ src/commands/verify-login.spec.ts | 246 ++++++++++++++++++++++++++++++ src/commands/verify-login.ts | 212 +++++++++++++++++++++++++ src/utils/help-json.spec.ts | 1 + src/utils/help-json.ts | 25 +++ 5 files changed, 528 insertions(+) create mode 100644 src/commands/verify-login.spec.ts create mode 100644 src/commands/verify-login.ts 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)',