Skip to content

Commit 66a33cc

Browse files
committed
feat: add outbound auth wizard step with OAuth and API Key credential creation
Add outbound-auth step to gateway-target TUI wizard after gateway/host selection: - Auth type selection: No authorization, OAuth 2LO, API Key - Credential selection: create new or use existing - OAuth inline creation: name, client ID, masked client secret, discovery URL - API Key inline creation: name, masked API key - Confirm screen shows auth type and credential name - Error handling resets sub-flow on credential creation failure
1 parent d486ba0 commit 66a33cc

File tree

3 files changed

+304
-15
lines changed

3 files changed

+304
-15
lines changed

src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx

Lines changed: 270 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { ToolNameSchema } from '../../../../schema';
2-
import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components';
2+
import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components';
33
import type { SelectableItem } from '../../components';
44
import { HELP_TEXT } from '../../constants';
55
import { useListNavigation } from '../../hooks';
66
import { generateUniqueName } from '../../utils';
7+
import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js';
78
import type { AddGatewayTargetConfig, ComputeHost, TargetLanguage } from './types';
89
import {
910
COMPUTE_HOST_OPTIONS,
1011
MCP_TOOL_STEP_LABELS,
12+
OUTBOUND_AUTH_OPTIONS,
1113
SKIP_FOR_NOW,
1214
SOURCE_OPTIONS,
1315
TARGET_LANGUAGE_OPTIONS,
1416
} from './types';
1517
import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard';
1618
import { Box, Text } from 'ink';
17-
import React, { useMemo } from 'react';
19+
import React, { useMemo, useState } from 'react';
1820

1921
interface AddGatewayTargetScreenProps {
2022
existingGateways: string[];
@@ -30,6 +32,17 @@ export function AddGatewayTargetScreen({
3032
onExit,
3133
}: AddGatewayTargetScreenProps) {
3234
const wizard = useAddGatewayTargetWizard(existingGateways);
35+
const { names: existingCredentialNames } = useExistingCredentialNames();
36+
const { createIdentity } = useCreateIdentity();
37+
38+
// Outbound auth sub-step state
39+
const [outboundAuthType, setOutboundAuthTypeLocal] = useState<string | null>(null);
40+
const [credentialName, setCredentialNameLocal] = useState<string | null>(null);
41+
const [isCreatingCredential, setIsCreatingCredential] = useState(false);
42+
const [oauthSubStep, setOauthSubStep] = useState<'name' | 'client-id' | 'client-secret' | 'discovery-url'>('name');
43+
const [oauthFields, setOauthFields] = useState({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
44+
const [apiKeySubStep, setApiKeySubStep] = useState<'name' | 'api-key'>('name');
45+
const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' });
3346

3447
const sourceItems: SelectableItem[] = useMemo(
3548
() => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
@@ -54,10 +67,26 @@ export function AddGatewayTargetScreen({
5467
[]
5568
);
5669

70+
const outboundAuthItems: SelectableItem[] = useMemo(
71+
() => OUTBOUND_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
72+
[]
73+
);
74+
75+
const credentialItems: SelectableItem[] = useMemo(() => {
76+
const items: SelectableItem[] = [
77+
{ id: 'create-new', title: 'Create new credential', description: 'Create a new credential inline' },
78+
];
79+
existingCredentialNames.forEach(name => {
80+
items.push({ id: name, title: name, description: 'Use existing credential' });
81+
});
82+
return items;
83+
}, [existingCredentialNames]);
84+
5785
const isSourceStep = wizard.step === 'source';
5886
const isLanguageStep = wizard.step === 'language';
5987
const isGatewayStep = wizard.step === 'gateway';
6088
const isHostStep = wizard.step === 'host';
89+
const isOutboundAuthStep = wizard.step === 'outbound-auth';
6190
const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint';
6291
const isConfirmStep = wizard.step === 'confirm';
6392
const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0;
@@ -90,16 +119,147 @@ export function AddGatewayTargetScreen({
90119
isActive: isHostStep,
91120
});
92121

122+
const outboundAuthNav = useListNavigation({
123+
items: outboundAuthItems,
124+
onSelect: item => {
125+
const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE';
126+
setOutboundAuthTypeLocal(authType);
127+
if (authType === 'NONE') {
128+
wizard.setOutboundAuth({ type: 'NONE' });
129+
}
130+
},
131+
onExit: () => wizard.goBack(),
132+
isActive: isOutboundAuthStep && !outboundAuthType,
133+
});
134+
135+
const credentialNav = useListNavigation({
136+
items: credentialItems,
137+
onSelect: item => {
138+
if (item.id === 'create-new') {
139+
setIsCreatingCredential(true);
140+
if (outboundAuthType === 'OAUTH') {
141+
setOauthSubStep('name');
142+
} else {
143+
setApiKeySubStep('name');
144+
}
145+
} else {
146+
setCredentialNameLocal(item.id);
147+
wizard.setOutboundAuth({ type: outboundAuthType as 'OAUTH' | 'API_KEY', credentialName: item.id });
148+
}
149+
},
150+
onExit: () => {
151+
setOutboundAuthTypeLocal(null);
152+
setCredentialNameLocal(null);
153+
setIsCreatingCredential(false);
154+
},
155+
isActive:
156+
isOutboundAuthStep &&
157+
!!outboundAuthType &&
158+
outboundAuthType !== 'NONE' &&
159+
!credentialName &&
160+
!isCreatingCredential,
161+
});
162+
93163
useListNavigation({
94164
items: [{ id: 'confirm', title: 'Confirm' }],
95165
onSelect: () => onComplete(wizard.config),
96166
onExit: () => wizard.goBack(),
97167
isActive: isConfirmStep,
98168
});
99169

170+
// OAuth creation handlers
171+
const handleOauthFieldSubmit = (value: string) => {
172+
const newFields = { ...oauthFields };
173+
174+
if (oauthSubStep === 'name') {
175+
newFields.name = value;
176+
setOauthFields(newFields);
177+
setOauthSubStep('client-id');
178+
} else if (oauthSubStep === 'client-id') {
179+
newFields.clientId = value;
180+
setOauthFields(newFields);
181+
setOauthSubStep('client-secret');
182+
} else if (oauthSubStep === 'client-secret') {
183+
newFields.clientSecret = value;
184+
setOauthFields(newFields);
185+
setOauthSubStep('discovery-url');
186+
} else if (oauthSubStep === 'discovery-url') {
187+
newFields.discoveryUrl = value;
188+
setOauthFields(newFields);
189+
190+
// Create the credential
191+
void createIdentity({
192+
type: 'OAuthCredentialProvider',
193+
name: newFields.name,
194+
clientId: newFields.clientId,
195+
clientSecret: newFields.clientSecret,
196+
discoveryUrl: newFields.discoveryUrl,
197+
}).then(result => {
198+
if (result.ok) {
199+
wizard.setOutboundAuth({ type: 'OAUTH', credentialName: newFields.name });
200+
} else {
201+
// Reset to credential selection on failure
202+
setIsCreatingCredential(false);
203+
setOauthSubStep('name');
204+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
205+
}
206+
});
207+
}
208+
};
209+
210+
const handleOauthFieldCancel = () => {
211+
if (oauthSubStep === 'name') {
212+
setIsCreatingCredential(false);
213+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
214+
} else if (oauthSubStep === 'client-id') {
215+
setOauthSubStep('name');
216+
} else if (oauthSubStep === 'client-secret') {
217+
setOauthSubStep('client-id');
218+
} else if (oauthSubStep === 'discovery-url') {
219+
setOauthSubStep('client-secret');
220+
}
221+
};
222+
223+
// API Key creation handlers
224+
const handleApiKeyFieldSubmit = (value: string) => {
225+
const newFields = { ...apiKeyFields };
226+
227+
if (apiKeySubStep === 'name') {
228+
newFields.name = value;
229+
setApiKeyFields(newFields);
230+
setApiKeySubStep('api-key');
231+
} else if (apiKeySubStep === 'api-key') {
232+
newFields.apiKey = value;
233+
setApiKeyFields(newFields);
234+
235+
void createIdentity({
236+
type: 'ApiKeyCredentialProvider',
237+
name: newFields.name,
238+
apiKey: newFields.apiKey,
239+
}).then(result => {
240+
if (result.ok) {
241+
wizard.setOutboundAuth({ type: 'API_KEY', credentialName: newFields.name });
242+
} else {
243+
setIsCreatingCredential(false);
244+
setApiKeySubStep('name');
245+
setApiKeyFields({ name: '', apiKey: '' });
246+
}
247+
});
248+
}
249+
};
250+
251+
const handleApiKeyFieldCancel = () => {
252+
if (apiKeySubStep === 'name') {
253+
setIsCreatingCredential(false);
254+
setApiKeyFields({ name: '', apiKey: '' });
255+
} else if (apiKeySubStep === 'api-key') {
256+
setApiKeySubStep('name');
257+
}
258+
};
259+
100260
const helpText = isConfirmStep
101261
? HELP_TEXT.CONFIRM_CANCEL
102-
: isTextStep
262+
: isTextStep || isCreatingCredential
103263
? HELP_TEXT.TEXT_INPUT
104264
: HELP_TEXT.NAVIGATE_SELECT;
105265

@@ -141,6 +301,107 @@ export function AddGatewayTargetScreen({
141301
/>
142302
)}
143303

304+
{isOutboundAuthStep && !outboundAuthType && (
305+
<WizardSelect
306+
title="Select outbound authentication"
307+
description="How will this tool authenticate to external services?"
308+
items={outboundAuthItems}
309+
selectedIndex={outboundAuthNav.selectedIndex}
310+
/>
311+
)}
312+
313+
{isOutboundAuthStep &&
314+
outboundAuthType &&
315+
outboundAuthType !== 'NONE' &&
316+
!credentialName &&
317+
!isCreatingCredential && (
318+
<WizardSelect
319+
title="Select credential"
320+
description={`Choose a credential for ${outboundAuthType} authentication`}
321+
items={credentialItems}
322+
selectedIndex={credentialNav.selectedIndex}
323+
/>
324+
)}
325+
326+
{isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && (
327+
<>
328+
{oauthSubStep === 'name' && (
329+
<TextInput
330+
key="oauth-name"
331+
prompt="Credential name"
332+
initialValue={generateUniqueName('MyOAuth', existingCredentialNames)}
333+
onSubmit={handleOauthFieldSubmit}
334+
onCancel={handleOauthFieldCancel}
335+
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
336+
/>
337+
)}
338+
{oauthSubStep === 'client-id' && (
339+
<TextInput
340+
key="oauth-client-id"
341+
prompt="Client ID"
342+
onSubmit={handleOauthFieldSubmit}
343+
onCancel={handleOauthFieldCancel}
344+
customValidation={value => value.trim().length > 0 || 'Client ID is required'}
345+
/>
346+
)}
347+
{oauthSubStep === 'client-secret' && (
348+
<SecretInput
349+
key="oauth-client-secret"
350+
prompt="Client Secret"
351+
onSubmit={handleOauthFieldSubmit}
352+
onCancel={handleOauthFieldCancel}
353+
customValidation={value => value.trim().length > 0 || 'Client secret is required'}
354+
revealChars={4}
355+
/>
356+
)}
357+
{oauthSubStep === 'discovery-url' && (
358+
<TextInput
359+
key="oauth-discovery-url"
360+
prompt="Discovery URL"
361+
placeholder="https://example.com/.well-known/openid_configuration"
362+
onSubmit={handleOauthFieldSubmit}
363+
onCancel={handleOauthFieldCancel}
364+
customValidation={value => {
365+
try {
366+
const url = new URL(value);
367+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
368+
return 'Discovery URL must use http:// or https:// protocol';
369+
}
370+
return true;
371+
} catch {
372+
return 'Must be a valid URL';
373+
}
374+
}}
375+
/>
376+
)}
377+
</>
378+
)}
379+
380+
{isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'API_KEY' && (
381+
<>
382+
{apiKeySubStep === 'name' && (
383+
<TextInput
384+
key="apikey-name"
385+
prompt="Credential name"
386+
initialValue={generateUniqueName('MyApiKey', existingCredentialNames)}
387+
onSubmit={handleApiKeyFieldSubmit}
388+
onCancel={handleApiKeyFieldCancel}
389+
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
390+
/>
391+
)}
392+
{apiKeySubStep === 'api-key' && (
393+
<SecretInput
394+
key="apikey-value"
395+
prompt="API Key"
396+
onSubmit={handleApiKeyFieldSubmit}
397+
onCancel={handleApiKeyFieldCancel}
398+
customValidation={value => value.trim().length > 0 || 'API key is required'}
399+
revealChars={4}
400+
/>
401+
)}
402+
</>
403+
)}
404+
144405
{isTextStep && (
145406
<TextInput
146407
key={wizard.step}
@@ -183,6 +444,12 @@ export function AddGatewayTargetScreen({
183444
...(wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []),
184445
...(!wizard.config.gateway ? [{ label: 'Gateway', value: '(none - assign later)' }] : []),
185446
...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []),
447+
...(wizard.config.outboundAuth
448+
? [
449+
{ label: 'Auth Type', value: wizard.config.outboundAuth.type },
450+
{ label: 'Credential', value: wizard.config.outboundAuth.credentialName ?? 'None' },
451+
]
452+
: []),
186453
...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []),
187454
]}
188455
/>

src/cli/tui/screens/mcp/types.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime';
4343
* - host: Select compute host
4444
* - confirm: Review and confirm
4545
*/
46-
export type AddGatewayTargetStep = 'name' | 'source' | 'endpoint' | 'language' | 'gateway' | 'host' | 'confirm';
46+
export type AddGatewayTargetStep =
47+
| 'name'
48+
| 'source'
49+
| 'endpoint'
50+
| 'language'
51+
| 'gateway'
52+
| 'host'
53+
| 'outbound-auth'
54+
| 'confirm';
4755

4856
export type TargetLanguage = 'Python' | 'TypeScript' | 'Other';
4957

@@ -77,6 +85,7 @@ export const MCP_TOOL_STEP_LABELS: Record<AddGatewayTargetStep, string> = {
7785
language: 'Language',
7886
gateway: 'Gateway',
7987
host: 'Host',
88+
'outbound-auth': 'Outbound Auth',
8089
confirm: 'Confirm',
8190
};
8291

@@ -108,6 +117,12 @@ export const COMPUTE_HOST_OPTIONS = [
108117
{ id: 'AgentCoreRuntime', title: 'AgentCore Runtime', description: 'AgentCore Runtime (Python only)' },
109118
] as const;
110119

120+
export const OUTBOUND_AUTH_OPTIONS = [
121+
{ id: 'NONE', title: 'No authorization', description: 'No outbound authentication' },
122+
{ id: 'OAUTH', title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' },
123+
{ id: 'API_KEY', title: 'API Key', description: 'API key authentication' },
124+
] as const;
125+
111126
export const PYTHON_VERSION_OPTIONS = [
112127
{ id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' },
113128
{ id: 'PYTHON_3_12', title: 'Python 3.12', description: '' },

0 commit comments

Comments
 (0)