Skip to content
Draft
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
96 changes: 93 additions & 3 deletions src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const {
mockSetTokenVaultKmsKey,
mockReadEnvFile,
mockGetCredentialProvider,
mockGetApiKeyProvider,
mockOAuth2ProviderExists,
mockGetOAuth2Provider,
mockCreateOAuth2Provider,
mockUpdateOAuth2Provider,
} = vi.hoisted(() => ({
Expand All @@ -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(),
}));
Expand All @@ -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,
}));
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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' }],
Expand All @@ -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 () => {
Expand Down
61 changes: 52 additions & 9 deletions src/cli/operations/deploy/pre-deploy-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
apiKeyProviderExists,
createApiKeyProvider,
createOAuth2Provider,
getApiKeyProvider,
getOAuth2Provider,
oAuth2ProviderExists,
setTokenVaultKmsKey,
updateApiKeyProvider,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.`
),
};
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.`
),
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
apiKeyProviderExists,
createApiKeyProvider,
getApiKeyProvider,
setTokenVaultKmsKey,
updateApiKeyProvider,
} from '../api-key-credential-provider.js';
Expand Down Expand Up @@ -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());

Expand Down
20 changes: 20 additions & 0 deletions src/cli/operations/identity/api-key-credential-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<{ credentialProviderArn: string }>> {
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).
Expand Down
1 change: 1 addition & 0 deletions src/cli/operations/identity/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
apiKeyProviderExists,
createApiKeyProvider,
getApiKeyProvider,
setTokenVaultKmsKey,
updateApiKeyProvider,
} from './api-key-credential-provider';
Expand Down
4 changes: 4 additions & 0 deletions src/cli/tui/hooks/useCdkPreflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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') {
Expand Down
Loading