diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 625e22b5..e9a7992a 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -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', () => { @@ -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'); + }); + }); }); diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index 28b6d0cb..0d52fd3f 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -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 { @@ -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 { @@ -321,6 +324,23 @@ export async function handleAddGatewayTarget( options: ValidatedAddGatewayTargetOptions ): Promise { 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 }; @@ -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 { 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) { diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index cc5ae49c..df1dd80e 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -107,12 +107,25 @@ async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Prom process.exit(1); } + // Map CLI flag values to internal types + const outboundAuthMap: Record = { + 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) { @@ -170,10 +183,22 @@ async function handleAddIdentityCLI(options: AddIdentityOptions): Promise 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)); @@ -266,6 +291,12 @@ export function registerAdd(program: Command) { .option('--language ', 'Language: Python or TypeScript') .option('--gateway ', 'Gateway name') .option('--host ', 'Compute host: Lambda or AgentCoreRuntime') + .option('--outbound-auth ', 'Outbound auth type: oauth, api-key, or none') + .option('--credential-name ', 'Existing credential name for outbound auth') + .option('--oauth-client-id ', 'OAuth client ID (creates credential inline)') + .option('--oauth-client-secret ', 'OAuth client secret (creates credential inline)') + .option('--oauth-discovery-url ', 'OAuth discovery URL (creates credential inline)') + .option('--oauth-scopes ', 'OAuth scopes, comma-separated') .option('--json', 'Output as JSON') .action(async options => { requireProject(); @@ -293,7 +324,12 @@ export function registerAdd(program: Command) { .command('identity') .description('Add a credential to the project') .option('--name ', 'Credential name [non-interactive]') + .option('--type ', 'Credential type: api-key (default) or oauth') .option('--api-key ', 'The API key value [non-interactive]') + .option('--discovery-url ', 'OAuth discovery URL') + .option('--client-id ', 'OAuth client ID') + .option('--client-secret ', 'OAuth client secret') + .option('--scopes ', 'OAuth scopes, comma-separated') .option('--json', 'Output as JSON [non-interactive]') .action(async options => { requireProject(); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index b37785ba..f8847a7f 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -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; } @@ -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; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 0c4c0492..27af7fde 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -228,17 +228,47 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { - if (!options.credentialName) { + const hasInlineOAuth = !!(options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl); + + // 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; + } } } @@ -273,6 +303,26 @@ export function validateAddIdentityOptions(options: AddIdentityOptions): Validat 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' }; } diff --git a/src/cli/operations/identity/__tests__/credential-ops.test.ts b/src/cli/operations/identity/__tests__/credential-ops.test.ts index c2817483..a551fc2f 100644 --- a/src/cli/operations/identity/__tests__/credential-ops.test.ts +++ b/src/cli/operations/identity/__tests__/credential-ops.test.ts @@ -40,7 +40,7 @@ describe('createCredential', () => { mockWriteProjectSpec.mockResolvedValue(undefined); mockSetEnvVar.mockResolvedValue(undefined); - const result = await createCredential({ name: 'NewCred', apiKey: 'key123' }); + const result = await createCredential({ type: 'ApiKeyCredentialProvider', name: 'NewCred', apiKey: 'key123' }); expect(result.name).toBe('NewCred'); expect(result.type).toBe('ApiKeyCredentialProvider'); @@ -53,7 +53,7 @@ describe('createCredential', () => { mockReadProjectSpec.mockResolvedValue({ credentials: [existing] }); mockSetEnvVar.mockResolvedValue(undefined); - const result = await createCredential({ name: 'ExistCred', apiKey: 'newkey' }); + const result = await createCredential({ type: 'ApiKeyCredentialProvider', name: 'ExistCred', apiKey: 'newkey' }); expect(result).toBe(existing); expect(mockWriteProjectSpec).not.toHaveBeenCalled(); @@ -104,3 +104,101 @@ describe('resolveCredentialStrategy', () => { expect(result.isAgentScoped).toBe(true); }); }); + +describe('createCredential OAuth', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates OAuth credential and writes to project', async () => { + const project = { credentials: [] as any[] }; + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + const result = await createCredential({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + clientId: 'client123', + clientSecret: 'secret456', + }); + + expect(result.type).toBe('OAuthCredentialProvider'); + expect(result.name).toBe('my-oauth'); + expect(mockWriteProjectSpec).toHaveBeenCalled(); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.credentials[0]).toMatchObject({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', + vendor: 'CustomOauth2', + }); + }); + + it('writes CLIENT_ID and CLIENT_SECRET to env', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }); + + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_ID', 'cid'); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_SECRET', 'csec'); + }); + + it('uppercases name in env var keys', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'myOauth', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }); + + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MYOAUTH_CLIENT_ID', 'cid'); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MYOAUTH_CLIENT_SECRET', 'csec'); + }); + + it('throws when OAuth credential already exists', async () => { + mockReadProjectSpec.mockResolvedValue({ + credentials: [{ name: 'existing', type: 'OAuthCredentialProvider' }], + }); + + await expect( + createCredential({ + type: 'OAuthCredentialProvider', + name: 'existing', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + }) + ).rejects.toThrow('Credential "existing" already exists'); + }); + + it('includes scopes when provided', async () => { + mockReadProjectSpec.mockResolvedValue({ credentials: [] }); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockSetEnvVar.mockResolvedValue(undefined); + + await createCredential({ + type: 'OAuthCredentialProvider', + name: 'scoped', + discoveryUrl: 'https://example.com', + clientId: 'cid', + clientSecret: 'csec', + scopes: ['read', 'write'], + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.credentials[0].scopes).toEqual(['read', 'write']); + }); +}); diff --git a/src/cli/operations/identity/create-identity.ts b/src/cli/operations/identity/create-identity.ts index 0277df94..6c6705bb 100644 --- a/src/cli/operations/identity/create-identity.ts +++ b/src/cli/operations/identity/create-identity.ts @@ -4,10 +4,17 @@ import type { Credential, ModelProvider } from '../../../schema'; /** * Config for creating a credential resource. */ -export interface CreateCredentialConfig { - name: string; - apiKey: string; -} +export type CreateCredentialConfig = + | { type: 'ApiKeyCredentialProvider'; name: string; apiKey: string } + | { + type: 'OAuthCredentialProvider'; + name: string; + discoveryUrl: string; + clientId: string; + clientSecret: string; + scopes?: string[]; + vendor?: string; + }; /** * Result of resolving credential strategy for an agent. @@ -27,7 +34,7 @@ export interface CredentialStrategy { * Compute the default env var name for a credential. */ export function computeDefaultCredentialEnvVarName(credentialName: string): string { - return `AGENTCORE_CREDENTIAL_${credentialName.toUpperCase()}`; + return `AGENTCORE_CREDENTIAL_${credentialName.toUpperCase().replace(/-/g, '_')}`; } /** @@ -103,10 +110,7 @@ export async function getAllCredentialNames(): Promise { /** * Create a credential resource and add it to the project. - * Also writes the API key to the .env file. - * - * If the credential already exists (e.g., created during agent generation), - * just updates the API key in the .env file. + * Writes the credential config to agentcore.json and secrets to .env.local. */ export async function createCredential(config: CreateCredentialConfig): Promise { const configIO = new ConfigIO(); @@ -115,12 +119,34 @@ export async function createCredential(config: CreateCredentialConfig): Promise< // Check if credential already exists const existingCredential = project.credentials.find(c => c.name === config.name); + if (config.type === 'OAuthCredentialProvider') { + if (existingCredential) { + throw new Error(`Credential "${config.name}" already exists`); + } + + const credential: Credential = { + type: 'OAuthCredentialProvider', + name: config.name, + discoveryUrl: config.discoveryUrl, + vendor: config.vendor ?? 'CustomOauth2', + ...(config.scopes && config.scopes.length > 0 ? { scopes: config.scopes } : {}), + }; + project.credentials.push(credential); + await configIO.writeProjectSpec(project); + + // Write client ID and secret to .env.local + const envBase = computeDefaultCredentialEnvVarName(config.name); + await setEnvVar(`${envBase}_CLIENT_ID`, config.clientId); + await setEnvVar(`${envBase}_CLIENT_SECRET`, config.clientSecret); + + return credential; + } + + // ApiKeyCredentialProvider let credential: Credential; if (existingCredential) { - // updates credentital credential = existingCredential; } else { - // Create new credential entry credential = { type: 'ApiKeyCredentialProvider', name: config.name, @@ -129,7 +155,6 @@ export async function createCredential(config: CreateCredentialConfig): Promise< await configIO.writeProjectSpec(project); } - // Write API key to .env file const envVarName = computeDefaultCredentialEnvVarName(config.name); await setEnvVar(envVarName, config.apiKey); diff --git a/src/cli/tui/screens/identity/AddIdentityFlow.tsx b/src/cli/tui/screens/identity/AddIdentityFlow.tsx index 23061e5f..093331dc 100644 --- a/src/cli/tui/screens/identity/AddIdentityFlow.tsx +++ b/src/cli/tui/screens/identity/AddIdentityFlow.tsx @@ -35,7 +35,11 @@ export function AddIdentityFlow({ isInteractive = true, onExit, onBack, onDev, o const handleCreateComplete = useCallback( (config: AddIdentityConfig) => { - void createIdentity(config).then(result => { + void createIdentity({ + type: 'ApiKeyCredentialProvider', + name: config.name, + apiKey: config.apiKey, + }).then(result => { if (result.ok) { setFlow({ name: 'create-success', identityName: result.result.name }); return;