Skip to content

Commit 3252f32

Browse files
committed
feat: add OAuth credential support to add identity and outbound auth CLI flags (#416)
* feat: add OAuth credential support to add identity and outbound auth CLI flags Extend createCredential to support OAuth credentials alongside API keys: - CreateCredentialConfig is now a discriminated union (ApiKey vs OAuth) - OAuth writes CLIENT_ID and CLIENT_SECRET to .env.local - OAuth writes OAuthCredentialProvider config to agentcore.json Add CLI flags for non-interactive workflows: - add identity: --type oauth, --discovery-url, --client-id, --client-secret, --scopes - add gateway-target: --outbound-auth, --credential-name, --oauth-client-id, --oauth-client-secret, --oauth-discovery-url, --oauth-scopes - Inline OAuth credential creation when --oauth-* fields provided without --credential-name Adds 15 new tests covering OAuth credential creation, validation, and edge cases. * fix: use || instead of ?? for empty string handling and add discoveryUrl validation * fix: sanitize hyphens in credential env var names for POSIX compliance * test: update env var expectations for hyphen-to-underscore sanitization
1 parent 3f7cc97 commit 3252f32

8 files changed

Lines changed: 394 additions & 33 deletions

File tree

src/cli/commands/add/__tests__/validate.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,59 @@ describe('validate', () => {
369369
const result = await validateAddGatewayTargetOptions(options);
370370
expect(result.valid).toBe(true);
371371
});
372+
373+
// Outbound auth inline OAuth validation
374+
it('passes for OAUTH with inline OAuth fields', async () => {
375+
const result = await validateAddGatewayTargetOptions({
376+
...validGatewayTargetOptions,
377+
outboundAuthType: 'OAUTH',
378+
oauthClientId: 'cid',
379+
oauthClientSecret: 'csec',
380+
oauthDiscoveryUrl: 'https://auth.example.com',
381+
});
382+
expect(result.valid).toBe(true);
383+
});
384+
385+
it('returns error for OAUTH without credential-name or inline fields', async () => {
386+
const result = await validateAddGatewayTargetOptions({
387+
...validGatewayTargetOptions,
388+
outboundAuthType: 'OAUTH',
389+
});
390+
expect(result.valid).toBe(false);
391+
expect(result.error).toContain('--credential-name or inline OAuth fields');
392+
});
393+
394+
it('returns error for incomplete inline OAuth (missing client-secret)', async () => {
395+
const result = await validateAddGatewayTargetOptions({
396+
...validGatewayTargetOptions,
397+
outboundAuthType: 'OAUTH',
398+
oauthClientId: 'cid',
399+
oauthDiscoveryUrl: 'https://auth.example.com',
400+
});
401+
expect(result.valid).toBe(false);
402+
expect(result.error).toContain('--oauth-client-secret');
403+
});
404+
405+
it('returns error for API_KEY with inline OAuth fields', async () => {
406+
const result = await validateAddGatewayTargetOptions({
407+
...validGatewayTargetOptions,
408+
outboundAuthType: 'API_KEY',
409+
oauthClientId: 'cid',
410+
oauthClientSecret: 'csec',
411+
oauthDiscoveryUrl: 'https://auth.example.com',
412+
});
413+
expect(result.valid).toBe(false);
414+
expect(result.error).toContain('cannot be used with API_KEY');
415+
});
416+
417+
it('returns error for API_KEY without credential-name', async () => {
418+
const result = await validateAddGatewayTargetOptions({
419+
...validGatewayTargetOptions,
420+
outboundAuthType: 'API_KEY',
421+
});
422+
expect(result.valid).toBe(false);
423+
expect(result.error).toContain('--credential-name is required');
424+
});
372425
});
373426

374427
describe('validateAddMemoryOptions', () => {
@@ -465,4 +518,56 @@ describe('validate', () => {
465518
expect(validateAddIdentityOptions(validIdentityOptions)).toEqual({ valid: true });
466519
});
467520
});
521+
522+
describe('validateAddIdentityOptions OAuth', () => {
523+
it('passes for valid OAuth identity', () => {
524+
const result = validateAddIdentityOptions({
525+
name: 'my-oauth',
526+
type: 'oauth',
527+
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
528+
clientId: 'client123',
529+
clientSecret: 'secret456',
530+
});
531+
expect(result.valid).toBe(true);
532+
});
533+
534+
it('returns error for OAuth without discovery-url', () => {
535+
const result = validateAddIdentityOptions({
536+
name: 'my-oauth',
537+
type: 'oauth',
538+
clientId: 'client123',
539+
clientSecret: 'secret456',
540+
});
541+
expect(result.valid).toBe(false);
542+
expect(result.error).toContain('--discovery-url');
543+
});
544+
545+
it('returns error for OAuth without client-id', () => {
546+
const result = validateAddIdentityOptions({
547+
name: 'my-oauth',
548+
type: 'oauth',
549+
discoveryUrl: 'https://auth.example.com',
550+
clientSecret: 'secret456',
551+
});
552+
expect(result.valid).toBe(false);
553+
expect(result.error).toContain('--client-id');
554+
});
555+
556+
it('returns error for OAuth without client-secret', () => {
557+
const result = validateAddIdentityOptions({
558+
name: 'my-oauth',
559+
type: 'oauth',
560+
discoveryUrl: 'https://auth.example.com',
561+
clientId: 'client123',
562+
});
563+
expect(result.valid).toBe(false);
564+
expect(result.error).toContain('--client-secret');
565+
});
566+
567+
it('still requires api-key for default type', () => {
568+
const result = validateAddIdentityOptions({ name: 'my-key' });
569+
expect(result.valid).toBe(false);
570+
expect(result.error).toContain('--api-key');
571+
});
572+
});
468573
});

src/cli/commands/add/actions.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export interface ValidatedAddGatewayTargetOptions {
7474
host?: 'Lambda' | 'AgentCoreRuntime';
7575
outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE';
7676
credentialName?: string;
77+
oauthClientId?: string;
78+
oauthClientSecret?: string;
79+
oauthDiscoveryUrl?: string;
80+
oauthScopes?: string;
7781
}
7882

7983
export interface ValidatedAddMemoryOptions {
@@ -82,10 +86,9 @@ export interface ValidatedAddMemoryOptions {
8286
expiry?: number;
8387
}
8488

85-
export interface ValidatedAddIdentityOptions {
86-
name: string;
87-
apiKey: string;
88-
}
89+
export type ValidatedAddIdentityOptions =
90+
| { type: 'api-key'; name: string; apiKey: string }
91+
| { type: 'oauth'; name: string; discoveryUrl: string; clientId: string; clientSecret: string; scopes?: string };
8992

9093
// Agent handlers
9194
export async function handleAddAgent(options: ValidatedAddAgentOptions): Promise<AddAgentResult> {
@@ -321,6 +324,23 @@ export async function handleAddGatewayTarget(
321324
options: ValidatedAddGatewayTargetOptions
322325
): Promise<AddGatewayTargetResult> {
323326
try {
327+
// Auto-create OAuth credential when inline fields provided
328+
if (options.oauthClientId && options.oauthClientSecret && options.oauthDiscoveryUrl && !options.credentialName) {
329+
const credName = `${options.name}-oauth`;
330+
await createCredential({
331+
type: 'OAuthCredentialProvider',
332+
name: credName,
333+
discoveryUrl: options.oauthDiscoveryUrl,
334+
clientId: options.oauthClientId,
335+
clientSecret: options.oauthClientSecret,
336+
scopes: options.oauthScopes
337+
?.split(',')
338+
.map(s => s.trim())
339+
.filter(Boolean),
340+
});
341+
options.credentialName = credName;
342+
}
343+
324344
const config = buildGatewayTargetConfig(options);
325345
const result = await createToolFromWizard(config);
326346
return { success: true, toolName: result.toolName, sourcePath: result.projectPath };
@@ -355,10 +375,24 @@ export async function handleAddMemory(options: ValidatedAddMemoryOptions): Promi
355375
// Identity handler (v2: top-level credential resource, no owner/user)
356376
export async function handleAddIdentity(options: ValidatedAddIdentityOptions): Promise<AddIdentityResult> {
357377
try {
358-
const result = await createCredential({
359-
name: options.name,
360-
apiKey: options.apiKey,
361-
});
378+
const result =
379+
options.type === 'oauth'
380+
? await createCredential({
381+
type: 'OAuthCredentialProvider',
382+
name: options.name,
383+
discoveryUrl: options.discoveryUrl,
384+
clientId: options.clientId,
385+
clientSecret: options.clientSecret,
386+
scopes: options.scopes
387+
?.split(',')
388+
.map(s => s.trim())
389+
.filter(Boolean),
390+
})
391+
: await createCredential({
392+
type: 'ApiKeyCredentialProvider',
393+
name: options.name,
394+
apiKey: options.apiKey,
395+
});
362396

363397
return { success: true, credentialName: result.name };
364398
} catch (err) {

src/cli/commands/add/command.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,25 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom
107107
process.exit(1);
108108
}
109109

110+
// Map CLI flag values to internal types
111+
const outboundAuthMap: Record<string, 'OAUTH' | 'API_KEY' | 'NONE'> = {
112+
oauth: 'OAUTH',
113+
'api-key': 'API_KEY',
114+
none: 'NONE',
115+
};
116+
110117
const result = await handleAddGatewayTarget({
111118
name: options.name!,
112119
description: options.description,
113120
language: options.language! as 'Python' | 'TypeScript',
114121
gateway: options.gateway,
115122
host: options.host,
123+
outboundAuthType: options.outboundAuthType ? outboundAuthMap[options.outboundAuthType.toLowerCase()] : undefined,
124+
credentialName: options.credentialName,
125+
oauthClientId: options.oauthClientId,
126+
oauthClientSecret: options.oauthClientSecret,
127+
oauthDiscoveryUrl: options.oauthDiscoveryUrl,
128+
oauthScopes: options.oauthScopes,
116129
});
117130

118131
if (options.json) {
@@ -170,10 +183,22 @@ async function handleAddIdentityCLI(options: AddIdentityOptions): Promise<void>
170183
process.exit(1);
171184
}
172185

173-
const result = await handleAddIdentity({
174-
name: options.name!,
175-
apiKey: options.apiKey!,
176-
});
186+
const identityType = options.type ?? 'api-key';
187+
const result =
188+
identityType === 'oauth'
189+
? await handleAddIdentity({
190+
type: 'oauth',
191+
name: options.name!,
192+
discoveryUrl: options.discoveryUrl!,
193+
clientId: options.clientId!,
194+
clientSecret: options.clientSecret!,
195+
scopes: options.scopes,
196+
})
197+
: await handleAddIdentity({
198+
type: 'api-key',
199+
name: options.name!,
200+
apiKey: options.apiKey!,
201+
});
177202

178203
if (options.json) {
179204
console.log(JSON.stringify(result));
@@ -266,6 +291,12 @@ export function registerAdd(program: Command) {
266291
.option('--language <lang>', 'Language: Python or TypeScript')
267292
.option('--gateway <name>', 'Gateway name')
268293
.option('--host <host>', 'Compute host: Lambda or AgentCoreRuntime')
294+
.option('--outbound-auth <type>', 'Outbound auth type: oauth, api-key, or none')
295+
.option('--credential-name <name>', 'Existing credential name for outbound auth')
296+
.option('--oauth-client-id <id>', 'OAuth client ID (creates credential inline)')
297+
.option('--oauth-client-secret <secret>', 'OAuth client secret (creates credential inline)')
298+
.option('--oauth-discovery-url <url>', 'OAuth discovery URL (creates credential inline)')
299+
.option('--oauth-scopes <scopes>', 'OAuth scopes, comma-separated')
269300
.option('--json', 'Output as JSON')
270301
.action(async options => {
271302
requireProject();
@@ -293,7 +324,12 @@ export function registerAdd(program: Command) {
293324
.command('identity')
294325
.description('Add a credential to the project')
295326
.option('--name <name>', 'Credential name [non-interactive]')
327+
.option('--type <type>', 'Credential type: api-key (default) or oauth')
296328
.option('--api-key <key>', 'The API key value [non-interactive]')
329+
.option('--discovery-url <url>', 'OAuth discovery URL')
330+
.option('--client-id <id>', 'OAuth client ID')
331+
.option('--client-secret <secret>', 'OAuth client secret')
332+
.option('--scopes <scopes>', 'OAuth scopes, comma-separated')
297333
.option('--json', 'Output as JSON [non-interactive]')
298334
.action(async options => {
299335
requireProject();

src/cli/commands/add/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export interface AddGatewayTargetOptions {
5353
host?: 'Lambda' | 'AgentCoreRuntime';
5454
outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE';
5555
credentialName?: string;
56+
oauthClientId?: string;
57+
oauthClientSecret?: string;
58+
oauthDiscoveryUrl?: string;
59+
oauthScopes?: string;
5660
json?: boolean;
5761
}
5862

@@ -80,7 +84,12 @@ export interface AddMemoryResult {
8084
// Identity types (v2: credential, no owner/user concept)
8185
export interface AddIdentityOptions {
8286
name?: string;
87+
type?: 'api-key' | 'oauth';
8388
apiKey?: string;
89+
discoveryUrl?: string;
90+
clientId?: string;
91+
clientSecret?: string;
92+
scopes?: string;
8493
json?: boolean;
8594
}
8695

src/cli/commands/add/validate.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,47 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
228228

229229
// Validate outbound auth configuration
230230
if (options.outboundAuthType && options.outboundAuthType !== 'NONE') {
231-
if (!options.credentialName) {
231+
const hasInlineOAuth = !!(options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl);
232+
233+
// Reject inline OAuth fields with API_KEY auth type
234+
if (options.outboundAuthType === 'API_KEY' && hasInlineOAuth) {
232235
return {
233236
valid: false,
234-
error: `--credential-name is required when outbound auth type is ${options.outboundAuthType}`,
237+
error: 'Inline OAuth fields cannot be used with API_KEY outbound auth. Use --credential-name instead.',
235238
};
236239
}
237240

238-
// Validate that the credential exists
239-
const credentialValidation = await validateCredentialExists(options.credentialName);
240-
if (!credentialValidation.valid) {
241-
return credentialValidation;
241+
if (!options.credentialName && !hasInlineOAuth) {
242+
return {
243+
valid: false,
244+
error:
245+
options.outboundAuthType === 'API_KEY'
246+
? '--credential-name is required when outbound auth type is API_KEY'
247+
: `--credential-name or inline OAuth fields (--oauth-client-id, --oauth-client-secret, --oauth-discovery-url) required when outbound auth type is ${options.outboundAuthType}`,
248+
};
249+
}
250+
251+
// Validate inline OAuth fields are complete
252+
if (hasInlineOAuth) {
253+
if (!options.oauthClientId)
254+
return { valid: false, error: '--oauth-client-id is required for inline OAuth credential creation' };
255+
if (!options.oauthClientSecret)
256+
return { valid: false, error: '--oauth-client-secret is required for inline OAuth credential creation' };
257+
if (!options.oauthDiscoveryUrl)
258+
return { valid: false, error: '--oauth-discovery-url is required for inline OAuth credential creation' };
259+
try {
260+
new URL(options.oauthDiscoveryUrl);
261+
} catch {
262+
return { valid: false, error: '--oauth-discovery-url must be a valid URL' };
263+
}
264+
}
265+
266+
// Validate that referenced credential exists
267+
if (options.credentialName) {
268+
const credentialValidation = await validateCredentialExists(options.credentialName);
269+
if (!credentialValidation.valid) {
270+
return credentialValidation;
271+
}
242272
}
243273
}
244274

@@ -273,6 +303,26 @@ export function validateAddIdentityOptions(options: AddIdentityOptions): Validat
273303
return { valid: false, error: '--name is required' };
274304
}
275305

306+
const identityType = options.type ?? 'api-key';
307+
308+
if (identityType === 'oauth') {
309+
if (!options.discoveryUrl) {
310+
return { valid: false, error: '--discovery-url is required for OAuth credentials' };
311+
}
312+
try {
313+
new URL(options.discoveryUrl);
314+
} catch {
315+
return { valid: false, error: '--discovery-url must be a valid URL' };
316+
}
317+
if (!options.clientId) {
318+
return { valid: false, error: '--client-id is required for OAuth credentials' };
319+
}
320+
if (!options.clientSecret) {
321+
return { valid: false, error: '--client-secret is required for OAuth credentials' };
322+
}
323+
return { valid: true };
324+
}
325+
276326
if (!options.apiKey) {
277327
return { valid: false, error: '--api-key is required' };
278328
}

0 commit comments

Comments
 (0)