Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,59 @@ describe('validate', () => {
const result = await validateAddGatewayTargetOptions(options);
expect(result.valid).toBe(true);
});

// Outbound auth inline OAuth validation
it('passes for OAUTH with inline OAuth fields', async () => {
const result = await validateAddGatewayTargetOptions({
...validGatewayTargetOptions,
outboundAuthType: 'OAUTH',
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthDiscoveryUrl: 'https://auth.example.com',
});
expect(result.valid).toBe(true);
});

it('returns error for OAUTH without credential-name or inline fields', async () => {
const result = await validateAddGatewayTargetOptions({
...validGatewayTargetOptions,
outboundAuthType: 'OAUTH',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--credential-name or inline OAuth fields');
});

it('returns error for incomplete inline OAuth (missing client-secret)', async () => {
const result = await validateAddGatewayTargetOptions({
...validGatewayTargetOptions,
outboundAuthType: 'OAUTH',
oauthClientId: 'cid',
oauthDiscoveryUrl: 'https://auth.example.com',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--oauth-client-secret');
});

it('returns error for API_KEY with inline OAuth fields', async () => {
const result = await validateAddGatewayTargetOptions({
...validGatewayTargetOptions,
outboundAuthType: 'API_KEY',
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthDiscoveryUrl: 'https://auth.example.com',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('cannot be used with API_KEY');
});

it('returns error for API_KEY without credential-name', async () => {
const result = await validateAddGatewayTargetOptions({
...validGatewayTargetOptions,
outboundAuthType: 'API_KEY',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--credential-name is required');
});
});

describe('validateAddMemoryOptions', () => {
Expand Down Expand Up @@ -465,4 +518,56 @@ describe('validate', () => {
expect(validateAddIdentityOptions(validIdentityOptions)).toEqual({ valid: true });
});
});

describe('validateAddIdentityOptions OAuth', () => {
it('passes for valid OAuth identity', () => {
const result = validateAddIdentityOptions({
name: 'my-oauth',
type: 'oauth',
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
clientId: 'client123',
clientSecret: 'secret456',
});
expect(result.valid).toBe(true);
});

it('returns error for OAuth without discovery-url', () => {
const result = validateAddIdentityOptions({
name: 'my-oauth',
type: 'oauth',
clientId: 'client123',
clientSecret: 'secret456',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--discovery-url');
});

it('returns error for OAuth without client-id', () => {
const result = validateAddIdentityOptions({
name: 'my-oauth',
type: 'oauth',
discoveryUrl: 'https://auth.example.com',
clientSecret: 'secret456',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--client-id');
});

it('returns error for OAuth without client-secret', () => {
const result = validateAddIdentityOptions({
name: 'my-oauth',
type: 'oauth',
discoveryUrl: 'https://auth.example.com',
clientId: 'client123',
});
expect(result.valid).toBe(false);
expect(result.error).toContain('--client-secret');
});

it('still requires api-key for default type', () => {
const result = validateAddIdentityOptions({ name: 'my-key' });
expect(result.valid).toBe(false);
expect(result.error).toContain('--api-key');
});
});
});
50 changes: 42 additions & 8 deletions src/cli/commands/add/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export interface ValidatedAddGatewayTargetOptions {
host?: 'Lambda' | 'AgentCoreRuntime';
outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE';
credentialName?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthDiscoveryUrl?: string;
oauthScopes?: string;
}

export interface ValidatedAddMemoryOptions {
Expand All @@ -82,10 +86,9 @@ export interface ValidatedAddMemoryOptions {
expiry?: number;
}

export interface ValidatedAddIdentityOptions {
name: string;
apiKey: string;
}
export type ValidatedAddIdentityOptions =
| { type: 'api-key'; name: string; apiKey: string }
| { type: 'oauth'; name: string; discoveryUrl: string; clientId: string; clientSecret: string; scopes?: string };

// Agent handlers
export async function handleAddAgent(options: ValidatedAddAgentOptions): Promise<AddAgentResult> {
Expand Down Expand Up @@ -321,6 +324,23 @@ export async function handleAddGatewayTarget(
options: ValidatedAddGatewayTargetOptions
): Promise<AddGatewayTargetResult> {
try {
// Auto-create OAuth credential when inline fields provided
if (options.oauthClientId && options.oauthClientSecret && options.oauthDiscoveryUrl && !options.credentialName) {
const credName = `${options.name}-oauth`;
await createCredential({
type: 'OAuthCredentialProvider',
name: credName,
discoveryUrl: options.oauthDiscoveryUrl,
clientId: options.oauthClientId,
clientSecret: options.oauthClientSecret,
scopes: options.oauthScopes
?.split(',')
.map(s => s.trim())
.filter(Boolean),
});
options.credentialName = credName;
}

const config = buildGatewayTargetConfig(options);
const result = await createToolFromWizard(config);
return { success: true, toolName: result.toolName, sourcePath: result.projectPath };
Expand Down Expand Up @@ -355,10 +375,24 @@ export async function handleAddMemory(options: ValidatedAddMemoryOptions): Promi
// Identity handler (v2: top-level credential resource, no owner/user)
export async function handleAddIdentity(options: ValidatedAddIdentityOptions): Promise<AddIdentityResult> {
try {
const result = await createCredential({
name: options.name,
apiKey: options.apiKey,
});
const result =
options.type === 'oauth'
? await createCredential({
type: 'OAuthCredentialProvider',
name: options.name,
discoveryUrl: options.discoveryUrl,
clientId: options.clientId,
clientSecret: options.clientSecret,
scopes: options.scopes
?.split(',')
.map(s => s.trim())
.filter(Boolean),
})
: await createCredential({
type: 'ApiKeyCredentialProvider',
name: options.name,
apiKey: options.apiKey,
});

return { success: true, credentialName: result.name };
} catch (err) {
Expand Down
44 changes: 40 additions & 4 deletions src/cli/commands/add/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,25 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom
process.exit(1);
}

// Map CLI flag values to internal types
const outboundAuthMap: Record<string, 'OAUTH' | 'API_KEY' | 'NONE'> = {
oauth: 'OAUTH',
'api-key': 'API_KEY',
none: 'NONE',
};

const result = await handleAddGatewayTarget({
name: options.name!,
description: options.description,
language: options.language! as 'Python' | 'TypeScript',
gateway: options.gateway,
host: options.host,
outboundAuthType: options.outboundAuthType ? outboundAuthMap[options.outboundAuthType.toLowerCase()] : undefined,
credentialName: options.credentialName,
oauthClientId: options.oauthClientId,
oauthClientSecret: options.oauthClientSecret,
oauthDiscoveryUrl: options.oauthDiscoveryUrl,
oauthScopes: options.oauthScopes,
});

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

const result = await handleAddIdentity({
name: options.name!,
apiKey: options.apiKey!,
});
const identityType = options.type ?? 'api-key';
const result =
identityType === 'oauth'
? await handleAddIdentity({
type: 'oauth',
name: options.name!,
discoveryUrl: options.discoveryUrl!,
clientId: options.clientId!,
clientSecret: options.clientSecret!,
scopes: options.scopes,
})
: await handleAddIdentity({
type: 'api-key',
name: options.name!,
apiKey: options.apiKey!,
});

if (options.json) {
console.log(JSON.stringify(result));
Expand Down Expand Up @@ -266,6 +291,12 @@ export function registerAdd(program: Command) {
.option('--language <lang>', 'Language: Python or TypeScript')
.option('--gateway <name>', 'Gateway name')
.option('--host <host>', 'Compute host: Lambda or AgentCoreRuntime')
.option('--outbound-auth <type>', 'Outbound auth type: oauth, api-key, or none')
.option('--credential-name <name>', 'Existing credential name for outbound auth')
.option('--oauth-client-id <id>', 'OAuth client ID (creates credential inline)')
.option('--oauth-client-secret <secret>', 'OAuth client secret (creates credential inline)')
.option('--oauth-discovery-url <url>', 'OAuth discovery URL (creates credential inline)')
.option('--oauth-scopes <scopes>', 'OAuth scopes, comma-separated')
.option('--json', 'Output as JSON')
.action(async options => {
requireProject();
Expand Down Expand Up @@ -293,7 +324,12 @@ export function registerAdd(program: Command) {
.command('identity')
.description('Add a credential to the project')
.option('--name <name>', 'Credential name [non-interactive]')
.option('--type <type>', 'Credential type: api-key (default) or oauth')
.option('--api-key <key>', 'The API key value [non-interactive]')
.option('--discovery-url <url>', 'OAuth discovery URL')
.option('--client-id <id>', 'OAuth client ID')
.option('--client-secret <secret>', 'OAuth client secret')
.option('--scopes <scopes>', 'OAuth scopes, comma-separated')
.option('--json', 'Output as JSON [non-interactive]')
.action(async options => {
requireProject();
Expand Down
9 changes: 9 additions & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export interface AddGatewayTargetOptions {
host?: 'Lambda' | 'AgentCoreRuntime';
outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE';
credentialName?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthDiscoveryUrl?: string;
oauthScopes?: string;
json?: boolean;
}

Expand Down Expand Up @@ -80,7 +84,12 @@ export interface AddMemoryResult {
// Identity types (v2: credential, no owner/user concept)
export interface AddIdentityOptions {
name?: string;
type?: 'api-key' | 'oauth';
apiKey?: string;
discoveryUrl?: string;
clientId?: string;
clientSecret?: string;
scopes?: string;
json?: boolean;
}

Expand Down
62 changes: 56 additions & 6 deletions src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,47 @@

// Validate outbound auth configuration
if (options.outboundAuthType && options.outboundAuthType !== 'NONE') {
if (!options.credentialName) {
const hasInlineOAuth = !!(options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl);

Check failure on line 231 in src/cli/commands/add/validate.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator

Check failure on line 231 in src/cli/commands/add/validate.ts

View workflow job for this annotation

GitHub Actions / lint

Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator

// Reject inline OAuth fields with API_KEY auth type
if (options.outboundAuthType === 'API_KEY' && hasInlineOAuth) {
return {
valid: false,
error: `--credential-name is required when outbound auth type is ${options.outboundAuthType}`,
error: 'Inline OAuth fields cannot be used with API_KEY outbound auth. Use --credential-name instead.',
};
}

// Validate that the credential exists
const credentialValidation = await validateCredentialExists(options.credentialName);
if (!credentialValidation.valid) {
return credentialValidation;
if (!options.credentialName && !hasInlineOAuth) {
return {
valid: false,
error:
options.outboundAuthType === 'API_KEY'
? '--credential-name is required when outbound auth type is API_KEY'
: `--credential-name or inline OAuth fields (--oauth-client-id, --oauth-client-secret, --oauth-discovery-url) required when outbound auth type is ${options.outboundAuthType}`,
};
}

// Validate inline OAuth fields are complete
if (hasInlineOAuth) {
if (!options.oauthClientId)
return { valid: false, error: '--oauth-client-id is required for inline OAuth credential creation' };
if (!options.oauthClientSecret)
return { valid: false, error: '--oauth-client-secret is required for inline OAuth credential creation' };
if (!options.oauthDiscoveryUrl)
return { valid: false, error: '--oauth-discovery-url is required for inline OAuth credential creation' };
try {
new URL(options.oauthDiscoveryUrl);
} catch {
return { valid: false, error: '--oauth-discovery-url must be a valid URL' };
}
}

// Validate that referenced credential exists
if (options.credentialName) {
const credentialValidation = await validateCredentialExists(options.credentialName);
if (!credentialValidation.valid) {
return credentialValidation;
}
}
}

Expand Down Expand Up @@ -273,6 +303,26 @@
return { valid: false, error: '--name is required' };
}

const identityType = options.type ?? 'api-key';

if (identityType === 'oauth') {
if (!options.discoveryUrl) {
return { valid: false, error: '--discovery-url is required for OAuth credentials' };
}
try {
new URL(options.discoveryUrl);
} catch {
return { valid: false, error: '--discovery-url must be a valid URL' };
}
if (!options.clientId) {
return { valid: false, error: '--client-id is required for OAuth credentials' };
}
if (!options.clientSecret) {
return { valid: false, error: '--client-secret is required for OAuth credentials' };
}
return { valid: true };
}

if (!options.apiKey) {
return { valid: false, error: '--api-key is required' };
}
Expand Down
Loading
Loading