From a71741413875fef078d76b740f669042c9f0cf76 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 14:59:18 -0500 Subject: [PATCH 1/4] feat: add OAuth credential provider creation during deploy --- src/cli/cloudformation/outputs.ts | 8 +- src/cli/commands/deploy/actions.ts | 65 +++++-- src/cli/operations/deploy/index.ts | 5 + .../operations/deploy/pre-deploy-identity.ts | 140 ++++++++++++++- src/cli/operations/identity/index.ts | 8 + .../identity/oauth2-credential-provider.ts | 166 ++++++++++++++++++ src/schema/schemas/deployed-state.ts | 13 ++ 7 files changed, 390 insertions(+), 15 deletions(-) create mode 100644 src/cli/operations/identity/oauth2-credential-provider.ts 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..53a039f6 --- /dev/null +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -0,0 +1,166 @@ +/** + * 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 credential provider ARN from API response, handling field name inconsistency. + * The API may return the ARN as `credentialProviderArn` or `oAuth2CredentialProviderArn`. + */ +function extractArn(response: Record): string | undefined { + return ( + (response.credentialProviderArn as string | undefined) ?? + (response.oAuth2CredentialProviderArn as string | undefined) + ); +} + +/** + * Extract result fields from an OAuth2 API response. + */ +function extractResult(response: Record): OAuth2ProviderResult | undefined { + const credentialProviderArn = extractArn(response); + if (!credentialProviderArn) return undefined; + + const clientSecretArnRaw = response.clientSecretArn; + const clientSecretArn = + clientSecretArnRaw && typeof clientSecretArnRaw === 'object' && 'secretArn' in clientSecretArnRaw + ? (clientSecretArnRaw as { secretArn?: string }).secretArn + : undefined; + + return { + credentialProviderArn, + clientSecretArn, + callbackUrl: typeof response.callbackUrl === 'string' ? response.callbackUrl : undefined, + }; +} + +/** + * 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; + } +} + +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 as unknown as Record); + 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') { + // Unlike API key providers, OAuth needs the ARN back for deployed-state.json + return getOAuth2Provider(client, params.name); + } + 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 as unknown as Record); + 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 as unknown as Record); + 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(), }); From 8af12575f918741d6029c6220dd3cc046f015892 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 16:48:23 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20add=20clarifying=20comments=20for=20vendor=20config?= =?UTF-8?q?,=20race=20condition,=20and=20ARN=20inconsistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../identity/oauth2-credential-provider.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index 53a039f6..547e14d8 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -32,6 +32,14 @@ export interface OAuth2ProviderParams { * Extract credential provider ARN from API response, handling field name inconsistency. * The API may return the ARN as `credentialProviderArn` or `oAuth2CredentialProviderArn`. */ +/** + * Extract credential provider ARN from API response, handling field name inconsistency. + * The API may return the ARN as `credentialProviderArn` or `oAuth2CredentialProviderArn`. + * + * Note: This casts to Record to handle the inconsistency. The typed SDK + * response only declares `credentialProviderArn`, but older API versions may return + * `oAuth2CredentialProviderArn`. Remove the fallback once the API stabilizes. + */ function extractArn(response: Record): string | undefined { return ( (response.credentialProviderArn as string | undefined) ?? @@ -77,6 +85,12 @@ export async function oAuth2ProviderExists( } } +/** + * Build the OAuth2 provider config for Create/Update commands. + * Always uses customOauth2ProviderConfig regardless of vendor — the vendor field + * controls server-side behavior (token endpoints, scopes), but the config shape + * is the same for all vendors in the current API. + */ function buildOAuth2Config(params: OAuth2ProviderParams) { return { name: params.name, @@ -111,7 +125,10 @@ export async function createOAuth2Provider( } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { - // Unlike API key providers, OAuth needs the ARN back for deployed-state.json + // Unlike API key providers, OAuth needs the ARN back for deployed-state.json. + // This only triggers in a race condition (another process created between exists-check + // and create). The caller already routes to update for known-existing providers, so + // falling back to GET here is safe — the next deploy will update with fresh credentials. return getOAuth2Provider(client, params.name); } return { From c0f3fd5575e4a5f6a54c8d25b50c4421eeb60459 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 16:58:17 -0500 Subject: [PATCH 3/4] fix: use typed SDK responses instead of Record casts --- .../identity/oauth2-credential-provider.ts | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index 547e14d8..3fa56ba5 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -28,42 +28,20 @@ export interface OAuth2ProviderParams { clientSecret: string; } -/** - * Extract credential provider ARN from API response, handling field name inconsistency. - * The API may return the ARN as `credentialProviderArn` or `oAuth2CredentialProviderArn`. - */ -/** - * Extract credential provider ARN from API response, handling field name inconsistency. - * The API may return the ARN as `credentialProviderArn` or `oAuth2CredentialProviderArn`. - * - * Note: This casts to Record to handle the inconsistency. The typed SDK - * response only declares `credentialProviderArn`, but older API versions may return - * `oAuth2CredentialProviderArn`. Remove the fallback once the API stabilizes. - */ -function extractArn(response: Record): string | undefined { - return ( - (response.credentialProviderArn as string | undefined) ?? - (response.oAuth2CredentialProviderArn as string | undefined) - ); -} - /** * Extract result fields from an OAuth2 API response. + * All Create/Get/Update responses share the same shape. */ -function extractResult(response: Record): OAuth2ProviderResult | undefined { - const credentialProviderArn = extractArn(response); - if (!credentialProviderArn) return undefined; - - const clientSecretArnRaw = response.clientSecretArn; - const clientSecretArn = - clientSecretArnRaw && typeof clientSecretArnRaw === 'object' && 'secretArn' in clientSecretArnRaw - ? (clientSecretArnRaw as { secretArn?: string }).secretArn - : undefined; - +function extractResult(response: { + credentialProviderArn?: string; + clientSecretArn?: { secretArn?: string }; + callbackUrl?: string; +}): OAuth2ProviderResult | undefined { + if (!response.credentialProviderArn) return undefined; return { - credentialProviderArn, - clientSecretArn, - callbackUrl: typeof response.callbackUrl === 'string' ? response.callbackUrl : undefined, + credentialProviderArn: response.credentialProviderArn, + clientSecretArn: response.clientSecretArn?.secretArn, + callbackUrl: response.callbackUrl, }; } @@ -87,9 +65,10 @@ export async function oAuth2ProviderExists( /** * Build the OAuth2 provider config for Create/Update commands. - * Always uses customOauth2ProviderConfig regardless of vendor — the vendor field - * controls server-side behavior (token endpoints, scopes), but the config shape - * is the same for all vendors in the current API. + * 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 { @@ -117,7 +96,7 @@ export async function createOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response as unknown as Record); + const result = extractResult(response); if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } @@ -147,7 +126,7 @@ export async function getOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new GetOauth2CredentialProviderCommand({ name })); - const result = extractResult(response as unknown as Record); + const result = extractResult(response); if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } @@ -169,7 +148,7 @@ export async function updateOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response as unknown as Record); + const result = extractResult(response); if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } From efe901836b7f080af18d859395f6eab4d3810d0f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Feb 2026 17:00:00 -0500 Subject: [PATCH 4/4] fix: on conflict, update OAuth provider instead of GET to avoid silently ignoring new credentials --- src/cli/operations/identity/oauth2-credential-provider.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index 3fa56ba5..e148d61f 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -104,11 +104,9 @@ export async function createOAuth2Provider( } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { - // Unlike API key providers, OAuth needs the ARN back for deployed-state.json. - // This only triggers in a race condition (another process created between exists-check - // and create). The caller already routes to update for known-existing providers, so - // falling back to GET here is safe — the next deploy will update with fresh credentials. - return getOAuth2Provider(client, params.name); + // 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,