diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index b4ad92c5..7c23f71b 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -614,17 +614,43 @@ describe('validate', () => { expect(result.error).toContain('not applicable'); }); - it('rejects --outbound-auth for api-gateway type', async () => { + it('rejects --outbound-auth oauth for api-gateway type', async () => { const result = await validateAddGatewayTargetOptions({ name: 'my-api', type: 'api-gateway', restApiId: 'abc123', stage: 'prod', gateway: 'my-gateway', - outboundAuthType: 'NONE', + outboundAuthType: 'OAUTH', }); expect(result.valid).toBe(false); - expect(result.error).toContain('not applicable'); + expect(result.error).toContain('OAuth is not supported'); + }); + + it('accepts --outbound-auth api-key with --credential-name for api-gateway type', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + outboundAuthType: 'API_KEY', + credentialName: 'my-key', + }); + expect(result.valid).toBe(true); + }); + + it('rejects --outbound-auth api-key without --credential-name for api-gateway type', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + outboundAuthType: 'API_KEY', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--credential-name is required'); }); it('rejects --host with mcp-server type', async () => { diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 085052e1..a45f25eb 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -281,10 +281,13 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO return { valid: false, error: '--language is not applicable for api-gateway type' }; } if (options.outboundAuthType) { - return { valid: false, error: '--outbound-auth is not applicable for api-gateway type' }; - } - if (options.credentialName) { - return { valid: false, error: '--credential-name is not applicable for api-gateway type' }; + const authLower = options.outboundAuthType.toLowerCase(); + if (authLower === 'oauth') { + return { valid: false, error: 'OAuth is not supported for api-gateway type' }; + } + if ((authLower === 'api-key' || authLower === 'api_key') && !options.credentialName) { + return { valid: false, error: '--credential-name is required with --outbound-auth api-key' }; + } } if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) { return { valid: false, error: 'OAuth options are not applicable for api-gateway type' }; diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index b1483abd..6d214407 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -275,6 +275,7 @@ export class GatewayTargetPrimitive extends BasePrimitive = { oauth: 'OAUTH', 'api-key': 'API_KEY', + api_key: 'API_KEY', none: 'NONE', }; @@ -296,6 +297,16 @@ export class GatewayTargetPrimitive extends BasePrimitive; + /** Credential ARNs (API key + OAuth) from pre-deploy setup */ + allCredentials: Record; startPreflight: () => Promise; confirmTeardown: () => void; cancelTeardown: () => void; @@ -128,7 +128,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { const [runtimeCredentials, setRuntimeCredentials] = useState(null); const [skipIdentitySetup, setSkipIdentitySetup] = useState(false); const [identityKmsKeyArn, setIdentityKmsKeyArn] = useState(undefined); - const [oauthCredentials, setOauthCredentials] = useState< + const [allCredentials, setAllCredentials] = useState< Record >({}); const [teardownConfirmed, setTeardownConfirmed] = useState(false); @@ -723,7 +723,6 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { }; } } - setOauthCredentials(creds); Object.assign(deployedCredentials, creds); logger.endStep('success'); @@ -732,6 +731,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { // Write partial deployed state with credential ARNs before CDK synth if (Object.keys(deployedCredentials).length > 0) { + setAllCredentials(deployedCredentials); const configIO = new ConfigIO(); const target = context.awsTargets[0]; const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); @@ -890,7 +890,7 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { hasCredentialsError, missingCredentials, identityKmsKeyArn, - oauthCredentials, + allCredentials, startPreflight, confirmTeardown, cancelTeardown, diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index bb461ff8..383d8d1f 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -42,7 +42,7 @@ export interface PreSynthesized { stackNames: string[]; switchableIoHost?: SwitchableIoHost; identityKmsKeyArn?: string; - oauthCredentials?: Record; + allCredentials?: Record; } interface DeployFlowOptions { @@ -114,7 +114,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const stackNames = preSynthesized?.stackNames ?? preflight.stackNames; const switchableIoHost = preSynthesized?.switchableIoHost ?? preflight.switchableIoHost; const identityKmsKeyArn = preSynthesized?.identityKmsKeyArn ?? preflight.identityKmsKeyArn; - const oauthCredentials = preSynthesized?.oauthCredentials ?? preflight.oauthCredentials; + const allCredentials = preSynthesized?.allCredentials ?? preflight.allCredentials; const [publishAssetsStep, setPublishAssetsStep] = useState({ label: 'Publish assets', status: 'pending' }); const [deployStep, setDeployStep] = useState({ label: 'Deploy to AWS', status: 'pending' }); @@ -269,7 +269,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState existingState, identityKmsKeyArn, memories, - credentials: Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined, + credentials: Object.keys(allCredentials).length > 0 ? allCredentials : undefined, }); await configIO.writeDeployedState(deployedState); @@ -282,7 +282,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (allStatuses.length > 0) { setTargetStatuses(allStatuses); } - }, [context, stackNames, logger, identityKmsKeyArn, oauthCredentials]); + }, [context, stackNames, logger, identityKmsKeyArn, allCredentials]); // Start deploy when preflight completes OR when shouldStartDeploy is set useEffect(() => { diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index a92a7733..2e024e57 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -6,11 +6,11 @@ import { AddIdentityScreen } from '../identity/AddIdentityScreen'; import type { AddIdentityConfig } from '../identity/types'; import { useCreateIdentity, useExistingCredentials, useExistingIdentityNames } from '../identity/useCreateIdentity'; import { AddGatewayTargetScreen } from './AddGatewayTargetScreen'; -import type { AddGatewayTargetConfig, GatewayTargetWizardState } from './types'; +import type { AddGatewayTargetConfig, AddGatewayTargetStep, GatewayTargetWizardState } from './types'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = - | { name: 'create-wizard' } + | { name: 'create-wizard'; resumeConfig?: GatewayTargetWizardState; resumeStep?: AddGatewayTargetStep } | { name: 'creating-credential'; pendingConfig: GatewayTargetWizardState } | { name: 'create-success'; toolName: string; projectPath: string; loading?: boolean; loadingMessage?: string } | { name: 'error'; message: string }; @@ -45,6 +45,11 @@ export function AddGatewayTargetFlow({ [credentials] ); + const apiKeyCredentialNames = useMemo( + () => credentials.filter(c => c.type === 'ApiKeyCredentialProvider').map(c => c.name), + [credentials] + ); + // In non-interactive mode, exit after success (but not while loading) useEffect(() => { if (!isInteractive && flow.name === 'create-success' && !flow.loading) { @@ -110,22 +115,22 @@ export function AddGatewayTargetFlow({ void createIdentity(createConfig).then(result => { if (result.ok && flow.name === 'creating-credential') { const pending = flow.pendingConfig; - // Credential creation is only reachable from the mcpServer outbound-auth step - handleCreateComplete({ - targetType: 'mcpServer', - name: pending.name, - description: pending.description ?? `Tool for ${pending.name}`, - endpoint: pending.endpoint!, - gateway: pending.gateway!, - toolDefinition: pending.toolDefinition!, - outboundAuth: { type: 'OAUTH', credentialName: result.result.name }, + const authType = pending.targetType === 'apiGateway' ? 'API_KEY' : 'OAUTH'; + // Resume wizard at confirm step with the new credential attached + setFlow({ + name: 'create-wizard', + resumeConfig: { + ...pending, + outboundAuth: { type: authType, credentialName: result.result.name }, + }, + resumeStep: 'confirm', }); } else if (!result.ok) { setFlow({ name: 'error', message: result.error }); } }); }, - [flow, createIdentity, handleCreateComplete] + [flow, createIdentity] ); // Create wizard @@ -135,21 +140,33 @@ export function AddGatewayTargetFlow({ existingGateways={existingGateways} existingToolNames={existingToolNames} existingOAuthCredentialNames={oauthCredentialNames} + existingApiKeyCredentialNames={apiKeyCredentialNames} onComplete={handleCreateComplete} onCreateCredential={handleCreateCredential} onExit={onBack} + initialConfig={flow.resumeConfig} + initialStep={flow.resumeStep} /> ); } // Creating credential via identity screen if (flow.name === 'creating-credential') { + const resumeStep = flow.pendingConfig.targetType === 'apiGateway' ? 'api-gateway-auth' : 'outbound-auth'; return ( setFlow({ name: 'create-wizard' })} - initialType="OAuthCredentialProvider" + onExit={() => + setFlow({ + name: 'create-wizard', + resumeConfig: flow.pendingConfig, + resumeStep: resumeStep, + }) + } + initialType={ + flow.pendingConfig.targetType === 'apiGateway' ? 'ApiKeyCredentialProvider' : 'OAuthCredentialProvider' + } /> ); } diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 4637ddb1..4d3243f4 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -5,8 +5,13 @@ import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddGatewayTargetConfig, GatewayTargetWizardState } from './types'; -import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, TARGET_TYPE_OPTIONS } from './types'; +import type { + AddGatewayTargetConfig, + AddGatewayTargetStep, + ApiGatewayTargetConfig, + GatewayTargetWizardState, +} from './types'; +import { API_GATEWAY_AUTH_OPTIONS, MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, TARGET_TYPE_OPTIONS } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; import React, { useMemo, useState } from 'react'; @@ -15,20 +20,26 @@ interface AddGatewayTargetScreenProps { existingGateways: string[]; existingToolNames: string[]; existingOAuthCredentialNames: string[]; + existingApiKeyCredentialNames: string[]; onComplete: (config: AddGatewayTargetConfig) => void; onCreateCredential: (pendingConfig: GatewayTargetWizardState) => void; onExit: () => void; + initialConfig?: GatewayTargetWizardState; + initialStep?: AddGatewayTargetStep; } export function AddGatewayTargetScreen({ existingGateways, existingToolNames, existingOAuthCredentialNames, + existingApiKeyCredentialNames, onComplete, onCreateCredential, onExit, + initialConfig, + initialStep, }: AddGatewayTargetScreenProps) { - const wizard = useAddGatewayTargetWizard(existingGateways); + const wizard = useAddGatewayTargetWizard(existingGateways, initialConfig, initialStep); const [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); const [filterPath, setFilterPathLocal] = useState(null); @@ -65,9 +76,27 @@ export function AddGatewayTargetScreen({ const isRestApiIdStep = wizard.step === 'rest-api-id'; const isStageStep = wizard.step === 'stage'; const isToolFiltersStep = wizard.step === 'tool-filters'; + const isApiGatewayAuthStep = wizard.step === 'api-gateway-auth'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; + const [apiKeyAuthSelected, setApiKeyAuthSelected] = useState(false); + + const apiGatewayAuthItems: SelectableItem[] = useMemo( + () => API_GATEWAY_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + + const apiKeyCredentialItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = existingApiKeyCredentialNames.map(name => ({ + id: name, + title: name, + description: 'Use existing API key credential', + })); + items.push({ id: 'create-new', title: 'Create new credential', description: 'Create a new API key credential' }); + return items; + }, [existingApiKeyCredentialNames]); + const targetTypeNav = useListNavigation({ items: targetTypeItems, onSelect: item => wizard.setTargetType(item.id as GatewayTargetType), @@ -114,6 +143,39 @@ export function AddGatewayTargetScreen({ isActive: isOutboundAuthStep && outboundAuthType === 'OAUTH', }); + const apiGatewayAuthNav = useListNavigation({ + items: apiGatewayAuthItems, + onSelect: item => { + if (item.id === 'API_KEY') { + if (existingApiKeyCredentialNames.length === 0) { + onCreateCredential(wizard.config); + } else { + setApiKeyAuthSelected(true); + } + } else if (item.id === 'NONE') { + wizard.setApiGatewayAuth({ type: 'NONE' }); + } else { + // IAM — no outboundAuth needed (default) + wizard.setApiGatewayAuth(undefined); + } + }, + onExit: () => wizard.goBack(), + isActive: isApiGatewayAuthStep && !apiKeyAuthSelected, + }); + + const apiKeyCredentialNav = useListNavigation({ + items: apiKeyCredentialItems, + onSelect: item => { + if (item.id === 'create-new') { + onCreateCredential(wizard.config); + } else { + wizard.setApiGatewayAuth({ type: 'API_KEY', credentialName: item.id }); + } + }, + onExit: () => setApiKeyAuthSelected(false), + isActive: isApiGatewayAuthStep && apiKeyAuthSelected, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => { @@ -126,6 +188,7 @@ export function AddGatewayTargetScreen({ restApiId: c.restApiId!, stage: c.stage!, toolFilters: c.toolFilters, + outboundAuth: c.outboundAuth as ApiGatewayTargetConfig['outboundAuth'], }); } else { onComplete({ @@ -283,6 +346,24 @@ export function AddGatewayTargetScreen({ /> )} + {isApiGatewayAuthStep && !apiKeyAuthSelected && ( + + )} + + {isApiGatewayAuthStep && apiKeyAuthSelected && ( + + )} + {isConfirmStep && ( )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 9a0c6a06..8eb675ff 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -64,6 +64,7 @@ export type AddGatewayTargetStep = | 'rest-api-id' | 'stage' | 'tool-filters' + | 'api-gateway-auth' | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -118,6 +119,10 @@ export interface ApiGatewayTargetConfig { restApiId: string; stage: string; toolFilters?: { filterPath: string; methods: ApiGatewayHttpMethod[] }[]; + outboundAuth?: { + type: 'API_KEY' | 'NONE'; + credentialName?: string; + }; } export type AddGatewayTargetConfig = McpServerTargetConfig | ApiGatewayTargetConfig; @@ -133,6 +138,7 @@ export const MCP_TOOL_STEP_LABELS: Record = { 'rest-api-id': 'REST API ID', stage: 'Stage', 'tool-filters': 'Tool Filters', + 'api-gateway-auth': 'Authorization', confirm: 'Confirm', }; @@ -173,6 +179,12 @@ export const OUTBOUND_AUTH_OPTIONS = [ { id: 'OAUTH', title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' }, ] as const; +export const API_GATEWAY_AUTH_OPTIONS = [ + { id: 'IAM', title: 'IAM (recommended)', description: 'AWS IAM role-based authorization' }, + { id: 'API_KEY', title: 'API Key', description: 'API key credential' }, + { id: 'NONE', title: 'No authorization', description: 'No outbound authentication' }, +] as const; + export const PYTHON_VERSION_OPTIONS = [ { id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' }, { id: 'PYTHON_3_12', title: 'Python 3.12', description: '' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 7f55ca92..6559cb8b 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -22,9 +22,13 @@ function getDefaultConfig(): GatewayTargetWizardState { }; } -export function useAddGatewayTargetWizard(existingGateways: string[] = []) { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('name'); +export function useAddGatewayTargetWizard( + existingGateways: string[] = [], + initialConfig?: GatewayTargetWizardState, + initialStep?: AddGatewayTargetStep +) { + const [config, setConfig] = useState(() => initialConfig ?? getDefaultConfig()); + const [step, setStep] = useState(initialStep ?? 'name'); // Dynamic steps — recomputes when targetType changes const steps = useMemo(() => { @@ -32,7 +36,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { if (config.targetType) { switch (config.targetType) { case 'apiGateway': - baseSteps.push('rest-api-id', 'stage', 'tool-filters', 'gateway'); + baseSteps.push('rest-api-id', 'stage', 'tool-filters', 'gateway', 'api-gateway-auth'); break; case 'mcpServer': default: @@ -148,6 +152,14 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { [goToNextStep] ); + const setApiGatewayAuth = useCallback( + (outboundAuth?: { type: 'API_KEY' | 'NONE'; credentialName?: string }) => { + setConfig(c => ({ ...c, outboundAuth })); + goToNextStep(); + }, + [goToNextStep] + ); + return { config, step, @@ -163,6 +175,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setRestApiId, setStage, setToolFilters, + setApiGatewayAuth, reset, }; } diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index b0eed1b7..b2f322f1 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -357,10 +357,10 @@ export const AgentCoreGatewayTargetSchema = z path: ['toolDefinitions'], }); } - if (data.outboundAuth && data.outboundAuth.type !== 'NONE') { + if (data.outboundAuth?.type === 'OAUTH') { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'outboundAuth is not applicable for apiGateway target type', + message: 'OAuth is not supported for apiGateway target type', path: ['outboundAuth'], }); }