diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index d10aa154..7e053e09 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -175,7 +175,8 @@ export function buildDeployedState( agents: Record, gateways: Record, existingState?: DeployedState, - identityKmsKeyArn?: string + identityKmsKeyArn?: string, + credentials?: Record ): DeployedState { const targetState: TargetDeployedState = { resources: { @@ -192,6 +193,11 @@ export function buildDeployedState( }; } + // Add credential state if credentials exist + if (credentials && Object.keys(credentials).length > 0) { + targetState.resources!.credentials = credentials; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 1b022c99..b9cfc538 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -11,8 +11,10 @@ import { checkStackDeployability, getAllCredentials, hasOwnedIdentityApiProviders, + hasOwnedIdentityOAuthProviders, performStackTeardown, setupApiKeyProviders, + setupOAuth2Providers, synthesizeCdk, validateProject, } from '../../operations/deploy'; @@ -166,20 +168,21 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; - for (const cred of neededCredentials) { - const value = process.env[cred.envVarName]; - if (value) { - envCredentials[cred.envVarName] = value; - } + // Read runtime credentials from process.env (enables non-interactive deploy with -y) + const neededCredentials = getAllCredentials(context.projectSpec); + const envCredentials: Record = {}; + for (const cred of neededCredentials) { + const value = process.env[cred.envVarName]; + if (value) { + envCredentials[cred.envVarName] = value; } - const runtimeCredentials = - Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined; + } + const runtimeCredentials = + Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined; + + if (hasOwnedIdentityApiProviders(context.projectSpec)) { + startStep('Creating credentials...'); const identityResult = await setupApiKeyProviders({ projectSpec: context.projectSpec, @@ -200,6 +203,41 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; + if (hasOwnedIdentityOAuthProviders(context.projectSpec)) { + startStep('Creating OAuth credentials...'); + + const oauthResult = await setupOAuth2Providers({ + projectSpec: context.projectSpec, + configBaseDir: configIO.getConfigRoot(), + region: target.region, + runtimeCredentials, + }); + if (oauthResult.hasErrors) { + const errorResult = oauthResult.results.find(r => r.status === 'error'); + const errorMsg = errorResult?.error ?? 'OAuth credential setup failed'; + endStep('error', errorMsg); + logger.finalize(false); + return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; + } + + // Collect credential ARNs for deployed state + for (const result of oauthResult.results) { + if (result.credentialProviderArn) { + oauthCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + clientSecretArn: result.clientSecretArn, + callbackUrl: result.callbackUrl, + }; + } + } + endStep('success'); + } + // Deploy const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; @@ -273,7 +311,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { + const { projectSpec, configBaseDir, region, runtimeCredentials } = options; + const results: OAuth2ProviderSetupResult[] = []; + const credentials = getCredentialProvider(); + + const envVars = await readEnvFile(configBaseDir); + const envCredentials = SecureCredentials.fromEnvVars(envVars); + const allCredentials = runtimeCredentials ? envCredentials.merge(runtimeCredentials) : envCredentials; + + const client = new BedrockAgentCoreControlClient({ region, credentials }); + + for (const credential of projectSpec.credentials) { + if (credential.type === 'OAuthCredentialProvider') { + const result = await setupSingleOAuth2Provider(client, credential, allCredentials); + results.push(result); + } + } + + return { + results, + hasErrors: results.some(r => r.status === 'error'), + }; +} + +/** + * Check if the project has any OAuth credentials that need setup. + */ +export function hasOwnedIdentityOAuthProviders(projectSpec: AgentCoreProjectSpec): boolean { + return projectSpec.credentials.some(c => c.type === 'OAuthCredentialProvider'); +} + +async function setupSingleOAuth2Provider( + client: BedrockAgentCoreControlClient, + credential: Credential, + credentials: SecureCredentials +): Promise { + if (credential.type !== 'OAuthCredentialProvider') { + return { providerName: credential.name, status: 'error', error: 'Invalid credential type' }; + } + + const nameKey = credential.name.toUpperCase().replace(/-/g, '_'); + const clientIdEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_ID`; + const clientSecretEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_SECRET`; + + const clientId = credentials.get(clientIdEnvVar); + const clientSecret = credentials.get(clientSecretEnvVar); + + if (!clientId || !clientSecret) { + return { + providerName: credential.name, + status: 'skipped', + error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`, + }; + } + + const params = { + name: credential.name, + vendor: credential.vendor, + discoveryUrl: credential.discoveryUrl, + clientId, + clientSecret, + }; + + try { + const exists = await oAuth2ProviderExists(client, credential.name); + + if (exists) { + const updateResult = await updateOAuth2Provider(client, params); + return { + providerName: credential.name, + status: updateResult.success ? 'updated' : 'error', + error: updateResult.error, + credentialProviderArn: updateResult.result?.credentialProviderArn, + clientSecretArn: updateResult.result?.clientSecretArn, + callbackUrl: updateResult.result?.callbackUrl, + }; + } + + const createResult = await createOAuth2Provider(client, params); + return { + providerName: credential.name, + status: createResult.success ? 'created' : 'error', + error: createResult.error, + credentialProviderArn: createResult.result?.credentialProviderArn, + clientSecretArn: createResult.result?.clientSecretArn, + callbackUrl: createResult.result?.callbackUrl, + }; + } catch (error) { + let errorMessage: string; + if (isNoCredentialsError(error)) { + errorMessage = 'AWS credentials not found. Run `aws sso login` or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.'; + } else { + errorMessage = error instanceof Error ? error.message : String(error); + } + return { providerName: credential.name, status: 'error', error: errorMessage }; + } +} diff --git a/src/cli/operations/identity/index.ts b/src/cli/operations/identity/index.ts index 5c9cd4ff..05c33e74 100644 --- a/src/cli/operations/identity/index.ts +++ b/src/cli/operations/identity/index.ts @@ -4,6 +4,14 @@ export { setTokenVaultKmsKey, updateApiKeyProvider, } from './api-key-credential-provider'; +export { + createOAuth2Provider, + getOAuth2Provider, + oAuth2ProviderExists, + updateOAuth2Provider, + type OAuth2ProviderParams, + type OAuth2ProviderResult, +} from './oauth2-credential-provider'; export { computeDefaultCredentialEnvVarName, resolveCredentialStrategy, diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts new file mode 100644 index 00000000..e148d61f --- /dev/null +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -0,0 +1,160 @@ +/** + * Imperative AWS SDK operations for OAuth2 credential providers. + * + * This file exists because AgentCore Identity resources are not yet modeled + * as CDK constructs. These operations run as a pre-deploy step outside the + * main CDK synthesis/deploy path. + */ +import { + BedrockAgentCoreControlClient, + CreateOauth2CredentialProviderCommand, + type CredentialProviderVendorType, + GetOauth2CredentialProviderCommand, + ResourceNotFoundException, + UpdateOauth2CredentialProviderCommand, +} from '@aws-sdk/client-bedrock-agentcore-control'; + +export interface OAuth2ProviderResult { + credentialProviderArn: string; + clientSecretArn?: string; + callbackUrl?: string; +} + +export interface OAuth2ProviderParams { + name: string; + vendor: string; + discoveryUrl: string; + clientId: string; + clientSecret: string; +} + +/** + * Extract result fields from an OAuth2 API response. + * All Create/Get/Update responses share the same shape. + */ +function extractResult(response: { + credentialProviderArn?: string; + clientSecretArn?: { secretArn?: string }; + callbackUrl?: string; +}): OAuth2ProviderResult | undefined { + if (!response.credentialProviderArn) return undefined; + return { + credentialProviderArn: response.credentialProviderArn, + clientSecretArn: response.clientSecretArn?.secretArn, + callbackUrl: response.callbackUrl, + }; +} + +/** + * Check if an OAuth2 credential provider exists. + */ +export async function oAuth2ProviderExists( + client: BedrockAgentCoreControlClient, + providerName: string +): Promise { + try { + await client.send(new GetOauth2CredentialProviderCommand({ name: providerName })); + return true; + } catch (error) { + if (error instanceof ResourceNotFoundException) { + return false; + } + throw error; + } +} + +/** + * Build the OAuth2 provider config for Create/Update commands. + * Always uses customOauth2ProviderConfig — the vendor field controls server-side + * behavior (token endpoints, scopes), but the config shape is the same for all + * vendors in the current API. Vendor-specific config paths (e.g. googleOauth2ProviderConfig) + * would be needed if we add vendor selection in a future phase. + */ +function buildOAuth2Config(params: OAuth2ProviderParams) { + return { + name: params.name, + credentialProviderVendor: params.vendor as CredentialProviderVendorType, + oauth2ProviderConfigInput: { + customOauth2ProviderConfig: { + clientId: params.clientId, + clientSecret: params.clientSecret, + oauthDiscovery: { + discoveryUrl: params.discoveryUrl, + }, + }, + }, + }; +} + +/** + * Create an OAuth2 credential provider. + * On conflict (already exists), falls back to GET to retrieve the ARN. + */ +export async function createOAuth2Provider( + client: BedrockAgentCoreControlClient, + params: OAuth2ProviderParams +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + const errorName = (error as { name?: string }).name; + if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { + // Race condition: another process created the provider between our exists-check and + // create call. Fall back to update so the user's credentials are always applied. + return updateOAuth2Provider(client, params); + } + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Get an existing OAuth2 credential provider. + */ +export async function getOAuth2Provider( + client: BedrockAgentCoreControlClient, + name: string +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new GetOauth2CredentialProviderCommand({ name })); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Update an existing OAuth2 credential provider. + */ +export async function updateOAuth2Provider( + client: BedrockAgentCoreControlClient, + params: OAuth2ProviderParams +): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { + try { + const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); + const result = extractResult(response); + if (!result) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 9438bae5..be6efacd 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -95,6 +95,18 @@ export const ExternallyManagedStateSchema = z.object({ export type ExternallyManagedState = z.infer; +// ============================================================================ +// Credential Deployed State +// ============================================================================ + +export const CredentialDeployedStateSchema = z.object({ + credentialProviderArn: z.string(), + clientSecretArn: z.string().optional(), + callbackUrl: z.string().optional(), +}); + +export type CredentialDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -103,6 +115,7 @@ export const DeployedResourceStateSchema = z.object({ agents: z.record(z.string(), AgentCoreDeployedStateSchema).optional(), mcp: McpDeployedStateSchema.optional(), externallyManaged: ExternallyManagedStateSchema.optional(), + credentials: z.record(z.string(), CredentialDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), });