diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index a26d60ecf..d2757a2a0 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts @@ -12,7 +12,9 @@ const { mockSetTokenVaultKmsKey, mockReadEnvFile, mockGetCredentialProvider, + mockGetApiKeyProvider, mockOAuth2ProviderExists, + mockGetOAuth2Provider, mockCreateOAuth2Provider, mockUpdateOAuth2Provider, } = vi.hoisted(() => ({ @@ -21,7 +23,9 @@ const { mockSetTokenVaultKmsKey: vi.fn(), mockReadEnvFile: vi.fn(), mockGetCredentialProvider: vi.fn(), + mockGetApiKeyProvider: vi.fn(), mockOAuth2ProviderExists: vi.fn(), + mockGetOAuth2Provider: vi.fn(), mockCreateOAuth2Provider: vi.fn(), mockUpdateOAuth2Provider: vi.fn(), })); @@ -47,9 +51,11 @@ vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ vi.mock('../../identity/index.js', () => ({ apiKeyProviderExists: vi.fn(), createApiKeyProvider: vi.fn(), + getApiKeyProvider: mockGetApiKeyProvider, setTokenVaultKmsKey: mockSetTokenVaultKmsKey, updateApiKeyProvider: vi.fn(), oAuth2ProviderExists: mockOAuth2ProviderExists, + getOAuth2Provider: mockGetOAuth2Provider, createOAuth2Provider: mockCreateOAuth2Provider, updateOAuth2Provider: mockUpdateOAuth2Provider, })); @@ -206,6 +212,56 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => { }); }); +describe('setupApiKeyCredentialProvider - secretless linking', () => { + afterEach(() => vi.clearAllMocks()); + + beforeEach(() => { + mockReadEnvFile.mockResolvedValue({}); + mockGetCredentialProvider.mockReturnValue({}); + }); + + const projectSpec = { + name: 'test-project', + credentials: [{ name: 'openai', authorizerType: 'ApiKeyCredentialProvider' }], + runtimes: [], + }; + + it('links an existing provider by name when no local secret exists', async () => { + mockGetApiKeyProvider.mockResolvedValue({ + success: true, + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai', + }); + + const result = await setupApiKeyProviders({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('linked'); + expect(result.results[0]!.credentialProviderArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai' + ); + expect(mockGetApiKeyProvider).toHaveBeenCalledWith(expect.anything(), 'openai'); + }); + + it('errors when no local secret and no remote provider exists', async () => { + mockGetApiKeyProvider.mockResolvedValue({ success: false, error: new Error('ResourceNotFoundException') }); + + const result = await setupApiKeyProviders({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(true); + expect(result.results[0]!.status).toBe('error'); + expect(result.results[0]!.error?.message).toContain('no existing AgentCore Identity API key credential provider'); + }); +}); + describe('hasIdentityOAuthProviders', () => { it('returns true when OAuthCredentialProvider exists', () => { const projectSpec = { @@ -280,6 +336,38 @@ describe('setupOAuth2Providers', () => { vi.clearAllMocks(); }); + it('links an existing OAuth2 provider by name when client credentials are missing', async () => { + mockReadEnvFile.mockResolvedValue({}); + mockGetOAuth2Provider.mockResolvedValue({ + success: true, + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/test-oauth', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:test-oauth', + callbackUrl: 'https://callback.example.com', + }); + + const projectSpec = { + credentials: [{ name: 'test-oauth', authorizerType: 'OAuthCredentialProvider' }], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toHaveLength(1); + expect(result.results[0]!.status).toBe('linked'); + expect(result.results[0]!.credentialProviderArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/test-oauth' + ); + expect(result.results[0]!.clientSecretArn).toBe('arn:aws:secretsmanager:us-east-1:123:secret:test-oauth'); + expect(result.results[0]!.callbackUrl).toBe('https://callback.example.com'); + expect(mockGetOAuth2Provider).toHaveBeenCalledWith(expect.anything(), 'test-oauth'); + expect(mockCreateOAuth2Provider).not.toHaveBeenCalled(); + expect(mockUpdateOAuth2Provider).not.toHaveBeenCalled(); + }); + it('creates OAuth2 provider when it does not exist', async () => { mockReadEnvFile.mockResolvedValue({ AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', @@ -350,8 +438,9 @@ describe('setupOAuth2Providers', () => { expect(mockUpdateOAuth2Provider).toHaveBeenCalled(); }); - it('skips when env vars are missing', async () => { + it('errors when env vars are missing and provider cannot be linked', async () => { mockReadEnvFile.mockResolvedValue({}); + mockGetOAuth2Provider.mockResolvedValue({ success: false, error: new Error('ResourceNotFoundException') }); const projectSpec = { credentials: [{ name: 'test-oauth', authorizerType: 'OAuthCredentialProvider' }], @@ -363,10 +452,11 @@ describe('setupOAuth2Providers', () => { region: 'us-east-1', }); - expect(result.hasErrors).toBe(false); + expect(result.hasErrors).toBe(true); expect(result.results).toHaveLength(1); - expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.status).toBe('error'); expect(result.results[0]!.error?.message).toContain('Missing'); + expect(result.results[0]!.error?.message).toContain('no existing AgentCore Identity OAuth2 credential provider'); }); it('returns error on failure', async () => { diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index d69e722d1..6f2beda99 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -26,6 +26,8 @@ import { apiKeyProviderExists, createApiKeyProvider, createOAuth2Provider, + getApiKeyProvider, + getOAuth2Provider, oAuth2ProviderExists, setTokenVaultKmsKey, updateApiKeyProvider, @@ -43,7 +45,7 @@ import { join } from 'path'; export interface ApiKeyProviderSetupResult { providerName: string; - status: 'created' | 'updated' | 'exists' | 'skipped' | 'error'; + status: 'created' | 'updated' | 'exists' | 'linked' | 'skipped' | 'error'; credentialProviderArn?: string; error?: Error; } @@ -173,10 +175,24 @@ async function setupApiKeyCredentialProvider( const apiKey = credentials.get(envVarName); if (!apiKey) { + // No local secret to create/update from. Link an existing provider of the same + // name (created in the console, another project, or via IaC) so its ARN reaches + // deployed state for CDK/gateway wiring. + const existing = await getApiKeyProvider(client, credential.name); + if (existing.success) { + return { + providerName: credential.name, + status: 'linked', + credentialProviderArn: existing.credentialProviderArn, + }; + } + return { providerName: credential.name, - status: 'skipped', - error: new Error(`No ${envVarName} found in agentcore/.env.local`), + status: 'error', + error: new MissingCredentialsError( + `No ${envVarName} found in agentcore/.env.local and no existing AgentCore Identity API key credential provider named "${credential.name}" was found.` + ), }; } @@ -332,7 +348,7 @@ export function assertEnvFileExists(projectSpec: AgentCoreProjectSpec, configBas export interface OAuth2ProviderSetupResult { providerName: string; - status: 'created' | 'updated' | 'skipped' | 'error'; + status: 'created' | 'updated' | 'linked' | 'skipped' | 'error'; error?: Error; credentialProviderArn?: string; clientSecretArn?: string; @@ -403,21 +419,48 @@ async function setupSingleOAuth2Provider( const clientSecret = credentials.get(clientSecretEnvVar); if (!clientId || !clientSecret) { + // No local secret to create/update from. Link an existing provider of the same + // name (created in the console, another project, or via IaC) so its ARN reaches + // deployed state for CDK/gateway wiring. + const existing = await getOAuth2Provider(client, credential.name); + if (existing.success) { + return { + providerName: credential.name, + status: 'linked', + credentialProviderArn: existing.credentialProviderArn, + clientSecretArn: existing.clientSecretArn, + callbackUrl: existing.callbackUrl, + }; + } + return { providerName: credential.name, - status: 'skipped', - error: new MissingCredentialsError(`Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`), + status: 'error', + error: new MissingCredentialsError( + `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local and no existing AgentCore Identity OAuth2 credential provider named "${credential.name}" was found.` + ), }; } // Imported OAuth providers may not have a discoveryUrl (provider already exists in Identity service). - // Skip create/update since we can't build a valid config without it. + // We can't build a valid create/update config without it, so link the existing provider by name. if (!credential.discoveryUrl) { + const existing = await getOAuth2Provider(client, credential.name); + if (existing.success) { + return { + providerName: credential.name, + status: 'linked', + credentialProviderArn: existing.credentialProviderArn, + clientSecretArn: existing.clientSecretArn, + callbackUrl: existing.callbackUrl, + }; + } + return { providerName: credential.name, - status: 'skipped', + status: 'error', error: new MissingCredentialsError( - `No discoveryUrl configured for "${credential.name}". Provider already exists in Identity service — credentials in .env.local will be ignored.` + `No discoveryUrl configured for "${credential.name}" and no existing AgentCore Identity OAuth2 credential provider with that name was found.` ), }; } diff --git a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts index 8018b6c1e..d4235f2a7 100644 --- a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts +++ b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts @@ -1,6 +1,7 @@ import { apiKeyProviderExists, createApiKeyProvider, + getApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider, } from '../api-key-credential-provider.js'; @@ -62,6 +63,26 @@ describe('apiKeyProviderExists', () => { }); }); +describe('getApiKeyProvider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns the credentialProviderArn when provider exists', async () => { + mockSend.mockResolvedValue({ credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov' }); + + expect(await getApiKeyProvider(makeMockClient(), 'prov')).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov', + }); + }); + + it('returns failure when provider does not exist', async () => { + mockSend.mockRejectedValue(new MockResourceNotFoundException()); + + const result = await getApiKeyProvider(makeMockClient(), 'prov'); + expect(result.success).toBe(false); + }); +}); + describe('createApiKeyProvider', () => { afterEach(() => vi.clearAllMocks()); diff --git a/src/cli/operations/identity/api-key-credential-provider.ts b/src/cli/operations/identity/api-key-credential-provider.ts index 0d8040371..be90f3317 100644 --- a/src/cli/operations/identity/api-key-credential-provider.ts +++ b/src/cli/operations/identity/api-key-credential-provider.ts @@ -34,6 +34,26 @@ export async function apiKeyProviderExists( } } +/** + * Get an existing API key credential provider's ARN by name. + * Used to link a provider that was created outside the project (console, another + * project, IaC) when no local secret is available to create/update it. + */ +export async function getApiKeyProvider( + client: BedrockAgentCoreControlClient, + providerName: string +): Promise> { + try { + const response = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + if (!response.credentialProviderArn) { + return err(toError('No credentialProviderArn in response')); + } + return ok({ credentialProviderArn: response.credentialProviderArn }); + } catch (error) { + return err(toError(error)); + } +} + /** * Create an API key credential provider. * Returns success even if provider already exists (idempotent). diff --git a/src/cli/operations/identity/index.ts b/src/cli/operations/identity/index.ts index 92a5b40a1..25dad28ec 100644 --- a/src/cli/operations/identity/index.ts +++ b/src/cli/operations/identity/index.ts @@ -1,6 +1,7 @@ export { apiKeyProviderExists, createApiKeyProvider, + getApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider, } from './api-key-credential-provider'; diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index b2462130e..3ca67e75d 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -799,6 +799,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.log(`Updated API key provider: ${result.providerName}`); } else if (result.status === 'exists') { logger.log(`API key provider exists: ${result.providerName}`); + } else if (result.status === 'linked') { + logger.log(`Linked API key provider: ${result.providerName}`); } else if (result.status === 'skipped') { logger.log(`Skipped ${result.providerName}: ${result.error?.message}`); } else if (result.status === 'error') { @@ -842,6 +844,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.log(`Created OAuth provider: ${result.providerName}`); } else if (result.status === 'updated') { logger.log(`Updated OAuth provider: ${result.providerName}`); + } else if (result.status === 'linked') { + logger.log(`Linked OAuth provider: ${result.providerName}`); } else if (result.status === 'skipped') { logger.log(`Skipped ${result.providerName}: ${result.error?.message}`); } else if (result.status === 'error') {