diff --git a/src/bin.ts b/src/bin.ts index 4cc0122f..7796f9db 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1392,7 +1392,7 @@ async function runCli(): Promise { ); return yargs.demandCommand(1, 'Please specify a session subcommand').strict(); }) - .command('connection', 'Manage SSO connections (read/delete)', (yargs) => { + .command('connection', 'Manage SSO connections (read/delete/test)', (yargs) => { yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); registerSubcommand( yargs, @@ -1439,6 +1439,35 @@ async function runCli(): Promise { await runConnectionGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ); + registerSubcommand( + yargs, + 'test ', + 'Test a connection by running an SSO login flow', + (y) => + y.positional('id', { type: 'string', demandOption: true }).options({ + 'client-id': { type: 'string', describe: 'WorkOS client ID' }, + port: { type: 'number', describe: 'Localhost port for the callback server', default: 4807 }, + timeout: { type: 'number', describe: 'Seconds to wait for the SSO callback', default: 300 }, + open: { type: 'boolean', describe: 'Open the authorization URL in a browser', default: true }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionTest } = await import('./commands/connection.js'); + await runConnectionTest( + argv.id, + { + clientId: argv.clientId, + port: argv.port, + timeoutSeconds: argv.timeout, + open: argv.open, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); registerSubcommand( yargs, 'delete ', diff --git a/src/commands/connection.spec.ts b/src/commands/connection.spec.ts index 8a47b567..6f6e603d 100644 --- a/src/commands/connection.spec.ts +++ b/src/commands/connection.spec.ts @@ -6,11 +6,44 @@ const mockSdk = { listConnections: vi.fn(), getConnection: vi.fn(), deleteConnection: vi.fn(), + getAuthorizationUrl: vi.fn(), + getProfileAndToken: vi.fn(), }, }; +const mockRedirectUriAdd = vi.fn(); + vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), + createWorkOSClient: () => ({ sdk: mockSdk, redirectUris: { add: mockRedirectUriAdd } }), +})); + +const mockGetActiveEnvironment = vi.fn(); + +vi.mock('../lib/config-store.js', () => ({ + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), +})); + +const mockOpen = vi.fn(); + +vi.mock('open', () => ({ + default: (...args: unknown[]) => mockOpen(...args), +})); + +type RequestHandler = (req: { url?: string }, res: unknown) => void; + +let requestHandler: RequestHandler | undefined; + +const mockServer = { + once: vi.fn(), + on: vi.fn((event: string, handler: RequestHandler) => { + if (event === 'request') requestHandler = handler; + }), + listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()), + close: vi.fn(), +}; + +vi.mock('node:http', () => ({ + default: { createServer: () => mockServer }, })); // Mock clack for confirmation prompts @@ -27,7 +60,7 @@ vi.mock('../utils/clack.js', () => ({ const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); -const { runConnectionList, runConnectionGet, runConnectionDelete } = await import('./connection.js'); +const { runConnectionList, runConnectionGet, runConnectionDelete, runConnectionTest } = await import('./connection.js'); const { CliExit } = await import('../utils/cli-exit.js'); const mockConnection = { @@ -58,6 +91,13 @@ describe('connection commands', () => { }); mockConfirm.mockResolvedValue(true); mockIsCancel.mockReturnValue(false); + requestHandler = undefined; + mockServer.on.mockImplementation((event: string, handler: RequestHandler) => { + if (event === 'request') requestHandler = handler; + }); + mockServer.listen.mockImplementation((_port: number, _host: string, cb: () => void) => cb()); + mockGetActiveEnvironment.mockReturnValue({ clientId: 'client_env' }); + mockOpen.mockResolvedValue(undefined); }); afterEach(() => { @@ -157,6 +197,139 @@ describe('connection commands', () => { }); }); + describe('runConnectionTest', () => { + function makeRes() { + const res = { + writeHead: vi.fn(() => res), + end: vi.fn(), + }; + return res; + } + + async function dispatchCallback(query: (state: string) => string): Promise { + await vi.waitFor(() => { + if (!requestHandler) throw new Error('request handler not registered'); + }); + const state = mockSdk.sso.getAuthorizationUrl.mock.calls[0][0].state; + requestHandler?.({ url: `/callback?${query(state)}` }, makeRes()); + } + + beforeEach(() => { + mockSdk.sso.getConnection.mockResolvedValue(mockConnection); + mockRedirectUriAdd.mockResolvedValue({ success: true, alreadyExists: false }); + mockSdk.sso.getAuthorizationUrl.mockReturnValue('https://api.workos.com/sso/authorize?mock=1'); + mockSdk.sso.getProfileAndToken.mockResolvedValue({ + profile: { + id: 'prof_01', + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + connectionId: 'conn_01ABC', + connectionType: 'OktaSAML', + organizationId: 'org_123', + idpId: 'idp_1', + }, + }); + }); + + it('registers redirect URI, opens browser, and exchanges the code', async () => { + const run = runConnectionTest('conn_01ABC', {}, 'sk_test'); + await dispatchCallback((state) => `code=auth_code_123&state=${state}`); + await run; + + expect(mockRedirectUriAdd).toHaveBeenCalledWith('http://localhost:4807/callback'); + expect(mockSdk.sso.getAuthorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client_env', + redirectUri: 'http://localhost:4807/callback', + connection: 'conn_01ABC', + }), + ); + expect(mockOpen).toHaveBeenCalledWith('https://api.workos.com/sso/authorize?mock=1'); + expect(mockSdk.sso.getProfileAndToken).toHaveBeenCalledWith({ code: 'auth_code_123', clientId: 'client_env' }); + expect(consoleOutput.some((l) => l.includes('user@example.com'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('SSO test succeeded'))).toBe(true); + expect(mockServer.close).toHaveBeenCalled(); + }); + + it('uses --client-id and --port over defaults', async () => { + const run = runConnectionTest('conn_01ABC', { clientId: 'client_flag', port: 9999 }, 'sk_test'); + await dispatchCallback((state) => `code=abc&state=${state}`); + await run; + + expect(mockRedirectUriAdd).toHaveBeenCalledWith('http://localhost:9999/callback'); + expect(mockSdk.sso.getAuthorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'client_flag', redirectUri: 'http://localhost:9999/callback' }), + ); + }); + + it('does not open the browser with open: false', async () => { + const run = runConnectionTest('conn_01ABC', { open: false }, 'sk_test'); + await dispatchCallback((state) => `code=abc&state=${state}`); + await run; + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it('fails when no client ID is available', async () => { + mockGetActiveEnvironment.mockReturnValue(null); + await expect(runConnectionTest('conn_01ABC', {}, 'sk_test')).rejects.toThrow(CliExit); + expect(mockSdk.sso.getAuthorizationUrl).not.toHaveBeenCalled(); + }); + + it('fails on IdP error callback', async () => { + const run = runConnectionTest('conn_01ABC', {}, 'sk_test'); + await dispatchCallback((state) => `error=access_denied&error_description=denied&state=${state}`); + await expect(run).rejects.toThrow(CliExit); + expect(mockSdk.sso.getProfileAndToken).not.toHaveBeenCalled(); + }); + + it('ignores callbacks with mismatched state and accepts the real one', async () => { + const run = runConnectionTest('conn_01ABC', {}, 'sk_test'); + await dispatchCallback(() => 'code=abc&state=wrong_state'); + expect(mockSdk.sso.getProfileAndToken).not.toHaveBeenCalled(); + await dispatchCallback((state) => `code=real_code&state=${state}`); + await run; + expect(mockSdk.sso.getProfileAndToken).toHaveBeenCalledWith({ code: 'real_code', clientId: 'client_env' }); + }); + + it('errors in agent mode when redirect URI registration fails', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + mockRedirectUriAdd.mockRejectedValue(new Error('forbidden')); + await expect(runConnectionTest('conn_01ABC', {}, 'sk_test')).rejects.toThrow(CliExit); + expect(mockSdk.sso.getAuthorizationUrl).not.toHaveBeenCalled(); + }); + + it('prompts to add redirect URI manually when registration fails', async () => { + mockRedirectUriAdd.mockRejectedValue(new Error('forbidden')); + mockConfirm.mockResolvedValue(true); + const run = runConnectionTest('conn_01ABC', {}, 'sk_test'); + await dispatchCallback((state) => `code=abc&state=${state}`); + await run; + expect(mockConfirm).toHaveBeenCalled(); + expect(mockSdk.sso.getProfileAndToken).toHaveBeenCalled(); + }); + + it('cancels when manual redirect URI prompt is declined', async () => { + mockRedirectUriAdd.mockRejectedValue(new Error('forbidden')); + mockConfirm.mockResolvedValue(false); + await runConnectionTest('conn_01ABC', {}, 'sk_test'); + expect(mockSdk.sso.getAuthorizationUrl).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true); + }); + + it('outputs JSON with profile in JSON mode', async () => { + setOutputMode('json'); + const run = runConnectionTest('conn_01ABC', {}, 'sk_test'); + await dispatchCallback((state) => `code=abc&state=${state}`); + await run; + const output = JSON.parse(consoleOutput[0]); + expect(output.connectionId).toBe('conn_01ABC'); + expect(output.redirectUri).toBe('http://localhost:4807/callback'); + expect(output.redirectUriRegistered).toBe(true); + expect(output.profile.email).toBe('user@example.com'); + }); + }); + describe('JSON output mode', () => { beforeEach(() => { setOutputMode('json'); diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 7053f080..59df083f 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -1,10 +1,14 @@ +import http from 'node:http'; import chalk from 'chalk'; -import type { ConnectionType } from '@workos-inc/node'; +import open from 'open'; +import type { ConnectionType, Profile } from '@workos-inc/node'; import { createWorkOSClient } from '../lib/workos-client.js'; +import { getActiveEnvironment } from '../lib/config-store.js'; import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; +import { isCiMode, isHumanMode, isPromptAllowed } from '../utils/interaction-mode.js'; +import { CliExit } from '../utils/cli-exit.js'; import clack from '../utils/clack.js'; const handleApiError = createApiErrorHandler('Connection'); @@ -127,3 +131,202 @@ export async function runConnectionDelete( handleApiError(error); } } + +export interface ConnectionTestOptions { + clientId?: string; + port?: number; + timeoutSeconds?: number; + open?: boolean; +} + +const DEFAULT_CALLBACK_PORT = 4807; +const DEFAULT_TEST_TIMEOUT_SECONDS = 300; + +interface CallbackResult { + code?: string; + error?: string; + errorDescription?: string; + state?: string; +} + +function resolveClientId(options: ConnectionTestOptions): string { + const clientId = options.clientId || process.env.WORKOS_CLIENT_ID || getActiveEnvironment()?.clientId; + if (!clientId) { + exitWithError({ + code: 'no_client_id', + message: + 'No client ID found. Pass --client-id, set WORKOS_CLIENT_ID, or run `workos env add` to configure an environment.', + }); + } + return clientId; +} + +function waitForCallback(server: http.Server, expectedState: string, timeoutSeconds: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Timed out after ${timeoutSeconds}s waiting for the SSO callback.`)); + }, timeoutSeconds * 1000); + timer.unref?.(); + + server.on('request', (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (url.pathname !== '/callback') { + res.writeHead(404).end(); + return; + } + + const result: CallbackResult = { + code: url.searchParams.get('code') ?? undefined, + error: url.searchParams.get('error') ?? undefined, + errorDescription: url.searchParams.get('error_description') ?? undefined, + state: url.searchParams.get('state') ?? undefined, + }; + + // Only settle when the state matches (success or IdP error — per + // RFC 6749 §4.1.2.1 error responses echo the original state). Stray + // requests keep the listener open. + if (result.state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Unexpected request

'); + return; + } + + const success = Boolean(result.code) && result.state === expectedState; + res.writeHead(success ? 200 : 400, { 'Content-Type': 'text/html' }); + res.end( + success + ? '

SSO test successful

You can close this tab and return to the terminal.

' + : '

SSO test failed

Check the terminal for details.

', + ); + + clearTimeout(timer); + resolve(result); + }); + }); +} + +export async function runConnectionTest( + id: string, + options: ConnectionTestOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + const clientId = resolveClientId(options); + const port = options.port ?? DEFAULT_CALLBACK_PORT; + const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TEST_TIMEOUT_SECONDS; + const redirectUri = `http://localhost:${port}/callback`; + + try { + const connection = await client.sdk.sso.getConnection(id); + if (connection.state !== 'active' && !isJsonMode()) { + console.log(chalk.yellow(`Warning: connection is ${connection.state} (not active).`)); + } + + // Register the localhost redirect URI in the environment so the + // authorize request passes redirect URI validation. + let redirectUriRegistered = false; + try { + const result = await client.redirectUris.add(redirectUri); + redirectUriRegistered = result.success; + if (!isJsonMode()) { + console.log( + result.alreadyExists + ? chalk.dim(`Redirect URI already registered: ${redirectUri}`) + : chalk.green(`Registered redirect URI: ${redirectUri}`), + ); + } + } catch { + if (!isPromptAllowed()) { + exitWithError({ + code: 'redirect_uri_registration_failed', + message: `Could not register redirect URI automatically. Add ${redirectUri} to your environment's redirect URIs in the WorkOS Dashboard, then re-run with the same --port.`, + }); + } + + console.log(chalk.yellow(`Could not register the redirect URI automatically.`)); + console.log(`Add the following redirect URI in the WorkOS Dashboard (Redirects section):`); + console.log(chalk.bold(` ${redirectUri}`)); + const confirmed = await clack.confirm({ message: 'Added the redirect URI? Continue with the test?' }); + if (clack.isCancel(confirmed) || !confirmed) { + console.log('Test cancelled.'); + return; + } + } + + const state = crypto.randomUUID(); + const authorizationUrl = client.sdk.sso.getAuthorizationUrl({ + clientId, + redirectUri, + connection: id, + state, + }); + + const server = http.createServer(); + const callbackPromise = waitForCallback(server, state, timeoutSeconds); + callbackPromise.catch(() => { + // Handled when awaited below; prevents an unhandled rejection if + // listen fails first. + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve()); + }); + + try { + if (!isJsonMode()) { + console.log(`\nOpen the following URL to start the SSO flow:`); + console.log(chalk.cyan(` ${authorizationUrl}`)); + console.log(chalk.dim(`\nWaiting for callback on ${redirectUri} (timeout: ${timeoutSeconds}s)...`)); + } + + if (options.open !== false && isHumanMode()) { + await open(authorizationUrl).catch(() => { + // Browser may not be available; the URL is already printed. + }); + } + + const callback = await callbackPromise; + + if (callback.error || !callback.code) { + exitWithError({ + code: 'sso_test_failed', + message: `SSO test failed: ${callback.error ?? 'no authorization code returned'}${ + callback.errorDescription ? ` — ${callback.errorDescription}` : '' + }`, + }); + } + + const { profile } = await client.sdk.sso.getProfileAndToken({ code: callback.code, clientId }); + + if (isJsonMode()) { + outputJson({ + connectionId: id, + redirectUri, + redirectUriRegistered, + authorizationUrl, + profile, + }); + return; + } + + printProfile(profile); + console.log(chalk.green('\nSSO test succeeded.')); + } finally { + server.close(); + } + } catch (error) { + if (error instanceof CliExit) throw error; + handleApiError(error); + } +} + +function printProfile(profile: Profile>): void { + console.log(chalk.bold('\nAuthenticated profile:')); + console.log(` ID: ${profile.id}`); + console.log(` Email: ${profile.email}`); + console.log(` Name: ${[profile.firstName, profile.lastName].filter(Boolean).join(' ') || chalk.dim('-')}`); + console.log(` Connection: ${profile.connectionId} (${profile.connectionType})`); + console.log(` Organization: ${profile.organizationId ?? chalk.dim('-')}`); + console.log(` IdP ID: ${profile.idpId}`); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b85e54a2..e21382a8 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -722,7 +722,7 @@ const commands: CommandSchema[] = [ }, { name: 'connection', - description: 'Manage SSO connections (read/delete)', + description: 'Manage SSO connections (read/delete/test)', options: [insecureStorageOpt, apiKeyOpt], commands: [ { @@ -739,6 +739,38 @@ const commands: CommandSchema[] = [ description: 'Get a connection', positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], }, + { + name: 'test', + description: 'Test a connection by running an SSO login flow', + positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], + options: [ + { name: 'client-id', type: 'string', description: 'WorkOS client ID', required: false, hidden: false }, + { + name: 'port', + type: 'number', + description: 'Localhost port for the callback server', + required: false, + default: 4807, + hidden: false, + }, + { + name: 'timeout', + type: 'number', + description: 'Seconds to wait for the SSO callback', + required: false, + default: 300, + hidden: false, + }, + { + name: 'open', + type: 'boolean', + description: 'Open the authorization URL in a browser', + required: false, + default: true, + hidden: false, + }, + ], + }, { name: 'delete', description: 'Delete a connection',