Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ agentcore deploy -y --json # JSON output
| `--diff` | Show CDK diff without deploying |
| `--json` | JSON output |

By default, deploying a project with credential resources creates or updates matching AgentCore Identity credential
providers using values from `agentcore/.env.local` or matching process environment variables. If no local credential
value is present, deploy looks for an existing AgentCore Identity credential provider with the same name and links its
ARN into `agentcore/.cli/deployed-state.json` for CDK/IAM wiring.

### status

Check deployment status and resource details.
Expand Down
5 changes: 4 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,10 @@ Strategy configuration:
| `usage` | No | `"inbound"` or `"outbound"` |

The actual secrets (API keys, client IDs, client secrets) are stored in `.env.local` for local development and in
AgentCore Identity service for deployed environments.
AgentCore Identity service for deployed environments. During deploy, the CLI creates or updates matching AgentCore
Identity credential providers from `agentcore/.env.local`. If no local secret is present, deploy attempts to link an
existing AgentCore Identity credential provider with the same credential name and records its ARN in
`.cli/deployed-state.json`.

---

Expand Down
6 changes: 4 additions & 2 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
{ credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }
> = {};

if (hasIdentityApiProviders(context.projectSpec)) {
const hasApiProviders = hasIdentityApiProviders(context.projectSpec);
const hasOAuthProviders = hasIdentityOAuthProviders(context.projectSpec);
if (hasApiProviders) {
startStep('Creating credentials...');

const identityResult = await setupApiKeyProviders({
Expand Down Expand Up @@ -177,7 +179,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
}

// Set up OAuth credential providers if needed
if (hasIdentityOAuthProviders(context.projectSpec)) {
if (hasOAuthProviders) {
startStep('Creating OAuth credentials...');

const oauthResult = await setupOAuth2Providers({
Expand Down
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 @@ -199,6 +205,47 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => {
});
});

describe('setupApiKeyProviders - existing provider linking', () => {
afterEach(() => vi.clearAllMocks());

beforeEach(() => {
mockReadEnvFile.mockResolvedValue({});
mockGetCredentialProvider.mockReturnValue({});
mockGetApiKeyProvider.mockResolvedValue({
success: true,
credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai',
});
});

it('links an existing API key provider by name when no local secret exists', async () => {
const projectSpec = {
name: 'test-project',
credentials: [{ name: 'openai', authorizerType: 'ApiKeyCredentialProvider' }],
runtimes: [],
};

const result = await setupApiKeyProviders({
projectSpec: projectSpec as any,
configBaseDir: '/tmp',
region: 'us-east-1',
enableKmsEncryption: true,
});

expect(result.hasErrors).toBe(false);
expect(result.kmsKeyArn).toBeUndefined();
expect(result.results).toEqual([
{
providerName: 'openai',
status: 'linked',
credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai',
},
]);
expect(mockGetApiKeyProvider).toHaveBeenCalledWith(expect.anything(), 'openai');
expect(mockControlSend).not.toHaveBeenCalled();
expect(mockKmsSend).not.toHaveBeenCalled();
});
});

describe('hasIdentityOAuthProviders', () => {
it('returns true when OAuthCredentialProvider exists', () => {
const projectSpec = {
Expand Down Expand Up @@ -273,6 +320,47 @@ describe('setupOAuth2Providers', () => {
vi.clearAllMocks();
});

it('links an existing OAuth2 provider by name when local client credentials are missing', async () => {
mockReadEnvFile.mockResolvedValue({});
mockGetOAuth2Provider.mockResolvedValue({
success: true,
result: {
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).toEqual([
{
providerName: 'test-oauth',
status: 'linked',
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',
},
]);
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 @@ -343,8 +431,9 @@ describe('setupOAuth2Providers', () => {
expect(mockUpdateOAuth2Provider).toHaveBeenCalled();
});

it('skips when env vars are missing', async () => {
it('returns error when env vars are missing and provider cannot be linked', async () => {
mockReadEnvFile.mockResolvedValue({});
mockGetOAuth2Provider.mockResolvedValue({ success: false, error: 'ResourceNotFoundException' });

const projectSpec = {
credentials: [{ name: 'test-oauth', authorizerType: 'OAuthCredentialProvider' }],
Expand All @@ -356,10 +445,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).toContain('Missing');
expect(result.results[0]!.error).toContain('no existing AgentCore Identity OAuth2 credential provider');
});

it('returns error on failure', async () => {
Expand Down
60 changes: 47 additions & 13 deletions src/cli/operations/deploy/pre-deploy-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
apiKeyProviderExists,
createApiKeyProvider,
createOAuth2Provider,
getApiKeyProvider,
getOAuth2Provider,
oAuth2ProviderExists,
setTokenVaultKmsKey,
updateApiKeyProvider,
Expand All @@ -22,7 +24,7 @@ import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms';

export interface ApiKeyProviderSetupResult {
providerName: string;
status: 'created' | 'updated' | 'exists' | 'skipped' | 'error';
status: 'created' | 'updated' | 'exists' | 'linked' | 'skipped' | 'error';
credentialProviderArn?: string;
error?: string;
}
Expand Down Expand Up @@ -64,9 +66,19 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions)

const client = new BedrockAgentCoreControlClient({ region, credentials });

// Configure KMS encryption for token vault if enabled
const apiKeyCredentials = projectSpec.credentials.filter(
credential => credential.authorizerType === 'ApiKeyCredentialProvider'
);

// Configure KMS encryption only when this deploy will write API key secrets.
let kmsKeyArn: string | undefined;
if (enableKmsEncryption) {
const hasApiKeyCredentialValue =
apiKeyCredentials.length === 0 ||
apiKeyCredentials.some(credential => {
const envVarName = computeDefaultCredentialEnvVarName(credential.name);
return Boolean(allCredentials.get(envVarName));
});
if (enableKmsEncryption && hasApiKeyCredentialValue) {
const kmsResult = await setupTokenVaultKms(region, credentials, projectSpec);
if (!kmsResult.success) {
return {
Expand All @@ -84,11 +96,9 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions)
}

// Set up each credential in the project
for (const credential of projectSpec.credentials) {
if (credential.authorizerType === 'ApiKeyCredentialProvider') {
const result = await setupApiKeyCredentialProvider(client, credential, allCredentials);
results.push(result);
}
for (const credential of apiKeyCredentials) {
const result = await setupApiKeyCredentialProvider(client, credential, allCredentials);
results.push(result);
}

return {
Expand Down Expand Up @@ -152,10 +162,21 @@ async function setupApiKeyCredentialProvider(
const apiKey = credentials.get(envVarName);

if (!apiKey) {
const existingProvider = await getApiKeyProvider(client, credential.name);
if (existingProvider.success && existingProvider.credentialProviderArn) {
return {
providerName: credential.name,
status: 'linked',
credentialProviderArn: existingProvider.credentialProviderArn,
};
}

return {
providerName: credential.name,
status: 'skipped',
error: `No ${envVarName} found in agentcore/.env.local`,
status: 'error',
error:
`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 @@ -263,7 +284,7 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre

export interface OAuth2ProviderSetupResult {
providerName: string;
status: 'created' | 'updated' | 'skipped' | 'error';
status: 'created' | 'updated' | 'linked' | 'skipped' | 'error';
error?: string;
credentialProviderArn?: string;
clientSecretArn?: string;
Expand Down Expand Up @@ -334,10 +355,23 @@ async function setupSingleOAuth2Provider(
const clientSecret = credentials.get(clientSecretEnvVar);

if (!clientId || !clientSecret) {
const existingProvider = await getOAuth2Provider(client, credential.name);
if (existingProvider.success && existingProvider.result) {
return {
providerName: credential.name,
status: 'linked',
credentialProviderArn: existingProvider.result.credentialProviderArn,
clientSecretArn: existingProvider.result.clientSecretArn,
callbackUrl: existingProvider.result.callbackUrl,
};
}

return {
providerName: credential.name,
status: 'skipped',
error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`,
status: 'error',
error:
`Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local and no existing ` +
`AgentCore Identity OAuth2 credential provider named "${credential.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,30 @@ describe('apiKeyProviderExists', () => {
});
});

describe('getApiKeyProvider', () => {
afterEach(() => vi.clearAllMocks());

it('returns the credential provider ARN when provider exists', async () => {
mockSend.mockResolvedValue({
credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/prov',
});

expect(await getApiKeyProvider(makeMockClient(), 'prov')).toEqual({
success: true,
credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/prov',
});
});

it('returns failure when provider response has no ARN', async () => {
mockSend.mockResolvedValue({});

expect(await getApiKeyProvider(makeMockClient(), 'prov')).toEqual({
success: false,
error: 'No credential provider ARN in response',
});
});
});

describe('createApiKeyProvider', () => {
afterEach(() => vi.clearAllMocks());

Expand Down
21 changes: 21 additions & 0 deletions src/cli/operations/identity/api-key-credential-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ export async function apiKeyProviderExists(
}
}

/**
* Get an existing API key credential provider.
*/
export async function getApiKeyProvider(
client: BedrockAgentCoreControlClient,
providerName: string
): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> {
try {
const response = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName }));
if (!response.credentialProviderArn) {
return { success: false, error: 'No credential provider ARN in response' };
}
return { success: true, credentialProviderArn: response.credentialProviderArn };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(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
Loading
Loading