Skip to content

Commit 268facf

Browse files
committed
feat: add OAuth credential provider creation during deploy (#407)
* feat: add OAuth credential provider creation during deploy * fix: address review comments — add clarifying comments for vendor config, race condition, and ARN inconsistency * fix: use typed SDK responses instead of Record<string, unknown> casts * fix: on conflict, update OAuth provider instead of GET to avoid silently ignoring new credentials
1 parent da7da9d commit 268facf

File tree

7 files changed

+384
-15
lines changed

7 files changed

+384
-15
lines changed

src/cli/cloudformation/outputs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,8 @@ export function buildDeployedState(
175175
agents: Record<string, AgentCoreDeployedState>,
176176
gateways: Record<string, { gatewayId: string; gatewayArn: string }>,
177177
existingState?: DeployedState,
178-
identityKmsKeyArn?: string
178+
identityKmsKeyArn?: string,
179+
credentials?: Record<string, { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }>
179180
): DeployedState {
180181
const targetState: TargetDeployedState = {
181182
resources: {
@@ -192,6 +193,11 @@ export function buildDeployedState(
192193
};
193194
}
194195

196+
// Add credential state if credentials exist
197+
if (credentials && Object.keys(credentials).length > 0) {
198+
targetState.resources!.credentials = credentials;
199+
}
200+
195201
return {
196202
targets: {
197203
...existingState?.targets,

src/cli/commands/deploy/actions.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
checkStackDeployability,
1212
getAllCredentials,
1313
hasOwnedIdentityApiProviders,
14+
hasOwnedIdentityOAuthProviders,
1415
performStackTeardown,
1516
setupApiKeyProviders,
17+
setupOAuth2Providers,
1618
synthesizeCdk,
1719
validateProject,
1820
} from '../../operations/deploy';
@@ -166,20 +168,21 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
166168

167169
// Set up identity providers if needed
168170
let identityKmsKeyArn: string | undefined;
169-
if (hasOwnedIdentityApiProviders(context.projectSpec)) {
170-
startStep('Creating credentials...');
171171

172-
// In CLI mode, also check process.env for credentials (enables non-interactive deploy with -y)
173-
const neededCredentials = getAllCredentials(context.projectSpec);
174-
const envCredentials: Record<string, string> = {};
175-
for (const cred of neededCredentials) {
176-
const value = process.env[cred.envVarName];
177-
if (value) {
178-
envCredentials[cred.envVarName] = value;
179-
}
172+
// Read runtime credentials from process.env (enables non-interactive deploy with -y)
173+
const neededCredentials = getAllCredentials(context.projectSpec);
174+
const envCredentials: Record<string, string> = {};
175+
for (const cred of neededCredentials) {
176+
const value = process.env[cred.envVarName];
177+
if (value) {
178+
envCredentials[cred.envVarName] = value;
180179
}
181-
const runtimeCredentials =
182-
Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined;
180+
}
181+
const runtimeCredentials =
182+
Object.keys(envCredentials).length > 0 ? new SecureCredentials(envCredentials) : undefined;
183+
184+
if (hasOwnedIdentityApiProviders(context.projectSpec)) {
185+
startStep('Creating credentials...');
183186

184187
const identityResult = await setupApiKeyProviders({
185188
projectSpec: context.projectSpec,
@@ -200,6 +203,41 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
200203
endStep('success');
201204
}
202205

206+
// Set up OAuth credential providers if needed
207+
const oauthCredentials: Record<
208+
string,
209+
{ credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }
210+
> = {};
211+
if (hasOwnedIdentityOAuthProviders(context.projectSpec)) {
212+
startStep('Creating OAuth credentials...');
213+
214+
const oauthResult = await setupOAuth2Providers({
215+
projectSpec: context.projectSpec,
216+
configBaseDir: configIO.getConfigRoot(),
217+
region: target.region,
218+
runtimeCredentials,
219+
});
220+
if (oauthResult.hasErrors) {
221+
const errorResult = oauthResult.results.find(r => r.status === 'error');
222+
const errorMsg = errorResult?.error ?? 'OAuth credential setup failed';
223+
endStep('error', errorMsg);
224+
logger.finalize(false);
225+
return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() };
226+
}
227+
228+
// Collect credential ARNs for deployed state
229+
for (const result of oauthResult.results) {
230+
if (result.credentialProviderArn) {
231+
oauthCredentials[result.providerName] = {
232+
credentialProviderArn: result.credentialProviderArn,
233+
clientSecretArn: result.clientSecretArn,
234+
callbackUrl: result.callbackUrl,
235+
};
236+
}
237+
}
238+
endStep('success');
239+
}
240+
203241
// Deploy
204242
const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0;
205243
const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS';
@@ -273,7 +311,8 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
273311
agents,
274312
gateways,
275313
existingState,
276-
identityKmsKeyArn
314+
identityKmsKeyArn,
315+
oauthCredentials
277316
);
278317
await configIO.writeDeployedState(deployedState);
279318

src/cli/operations/deploy/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ export {
1616
// Pre-deploy identity setup for non-Bedrock model providers
1717
export {
1818
setupApiKeyProviders,
19+
setupOAuth2Providers,
1920
hasOwnedIdentityApiProviders,
21+
hasOwnedIdentityOAuthProviders,
2022
getMissingCredentials,
2123
getAllCredentials,
2224
type SetupApiKeyProvidersOptions,
25+
type SetupOAuth2ProvidersOptions,
2326
type PreDeployIdentityResult,
27+
type PreDeployOAuth2Result,
2428
type ApiKeyProviderSetupResult,
29+
type OAuth2ProviderSetupResult,
2530
type MissingCredential,
2631
} from './pre-deploy-identity';
2732

src/cli/operations/deploy/pre-deploy-identity.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { isNoCredentialsError } from '../../errors';
55
import { getAwsLoginGuidance } from '../../external-requirements/checks';
66
import { apiKeyProviderExists, createApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider } from '../identity';
77
import { computeDefaultCredentialEnvVarName } from '../identity/create-identity';
8+
import {
9+
createOAuth2Provider,
10+
oAuth2ProviderExists,
11+
updateOAuth2Provider,
12+
} from '../identity/oauth2-credential-provider';
813
import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control';
914
import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms';
1015

@@ -223,7 +228,7 @@ export async function getMissingCredentials(
223228
}
224229

225230
/**
226-
* Get list of all API key credentials in the project (for manual entry prompt).
231+
* Get list of all credentials in the project that need env vars (for manual entry prompt and runtime credential reading).
227232
*/
228233
export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCredential[] {
229234
const credentials: MissingCredential[] = [];
@@ -234,8 +239,141 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre
234239
providerName: credential.name,
235240
envVarName: computeDefaultCredentialEnvVarName(credential.name),
236241
});
242+
} else if (credential.type === 'OAuthCredentialProvider') {
243+
const nameKey = credential.name.toUpperCase().replace(/-/g, '_');
244+
credentials.push(
245+
{ providerName: credential.name, envVarName: `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_ID` },
246+
{ providerName: credential.name, envVarName: `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_SECRET` }
247+
);
237248
}
238249
}
239250

240251
return credentials;
241252
}
253+
254+
// ─────────────────────────────────────────────────────────────────────────────
255+
// OAuth2 Credential Provider Setup
256+
// ─────────────────────────────────────────────────────────────────────────────
257+
258+
export interface OAuth2ProviderSetupResult {
259+
providerName: string;
260+
status: 'created' | 'updated' | 'skipped' | 'error';
261+
error?: string;
262+
credentialProviderArn?: string;
263+
clientSecretArn?: string;
264+
callbackUrl?: string;
265+
}
266+
267+
export interface SetupOAuth2ProvidersOptions {
268+
projectSpec: AgentCoreProjectSpec;
269+
configBaseDir: string;
270+
region: string;
271+
runtimeCredentials?: SecureCredentials;
272+
}
273+
274+
export interface PreDeployOAuth2Result {
275+
results: OAuth2ProviderSetupResult[];
276+
hasErrors: boolean;
277+
}
278+
279+
/**
280+
* Set up OAuth2 credential providers for all OAuth credentials in the project.
281+
* Reads client credentials from agentcore/.env.local and creates providers in AgentCore Identity.
282+
*/
283+
export async function setupOAuth2Providers(options: SetupOAuth2ProvidersOptions): Promise<PreDeployOAuth2Result> {
284+
const { projectSpec, configBaseDir, region, runtimeCredentials } = options;
285+
const results: OAuth2ProviderSetupResult[] = [];
286+
const credentials = getCredentialProvider();
287+
288+
const envVars = await readEnvFile(configBaseDir);
289+
const envCredentials = SecureCredentials.fromEnvVars(envVars);
290+
const allCredentials = runtimeCredentials ? envCredentials.merge(runtimeCredentials) : envCredentials;
291+
292+
const client = new BedrockAgentCoreControlClient({ region, credentials });
293+
294+
for (const credential of projectSpec.credentials) {
295+
if (credential.type === 'OAuthCredentialProvider') {
296+
const result = await setupSingleOAuth2Provider(client, credential, allCredentials);
297+
results.push(result);
298+
}
299+
}
300+
301+
return {
302+
results,
303+
hasErrors: results.some(r => r.status === 'error'),
304+
};
305+
}
306+
307+
/**
308+
* Check if the project has any OAuth credentials that need setup.
309+
*/
310+
export function hasOwnedIdentityOAuthProviders(projectSpec: AgentCoreProjectSpec): boolean {
311+
return projectSpec.credentials.some(c => c.type === 'OAuthCredentialProvider');
312+
}
313+
314+
async function setupSingleOAuth2Provider(
315+
client: BedrockAgentCoreControlClient,
316+
credential: Credential,
317+
credentials: SecureCredentials
318+
): Promise<OAuth2ProviderSetupResult> {
319+
if (credential.type !== 'OAuthCredentialProvider') {
320+
return { providerName: credential.name, status: 'error', error: 'Invalid credential type' };
321+
}
322+
323+
const nameKey = credential.name.toUpperCase().replace(/-/g, '_');
324+
const clientIdEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_ID`;
325+
const clientSecretEnvVar = `AGENTCORE_CREDENTIAL_${nameKey}_CLIENT_SECRET`;
326+
327+
const clientId = credentials.get(clientIdEnvVar);
328+
const clientSecret = credentials.get(clientSecretEnvVar);
329+
330+
if (!clientId || !clientSecret) {
331+
return {
332+
providerName: credential.name,
333+
status: 'skipped',
334+
error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`,
335+
};
336+
}
337+
338+
const params = {
339+
name: credential.name,
340+
vendor: credential.vendor,
341+
discoveryUrl: credential.discoveryUrl,
342+
clientId,
343+
clientSecret,
344+
};
345+
346+
try {
347+
const exists = await oAuth2ProviderExists(client, credential.name);
348+
349+
if (exists) {
350+
const updateResult = await updateOAuth2Provider(client, params);
351+
return {
352+
providerName: credential.name,
353+
status: updateResult.success ? 'updated' : 'error',
354+
error: updateResult.error,
355+
credentialProviderArn: updateResult.result?.credentialProviderArn,
356+
clientSecretArn: updateResult.result?.clientSecretArn,
357+
callbackUrl: updateResult.result?.callbackUrl,
358+
};
359+
}
360+
361+
const createResult = await createOAuth2Provider(client, params);
362+
return {
363+
providerName: credential.name,
364+
status: createResult.success ? 'created' : 'error',
365+
error: createResult.error,
366+
credentialProviderArn: createResult.result?.credentialProviderArn,
367+
clientSecretArn: createResult.result?.clientSecretArn,
368+
callbackUrl: createResult.result?.callbackUrl,
369+
};
370+
} catch (error) {
371+
let errorMessage: string;
372+
if (isNoCredentialsError(error)) {
373+
errorMessage = 'AWS credentials not found. Run `aws sso login` or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.';
374+
} else {
375+
errorMessage = error instanceof Error ? error.message : String(error);
376+
}
377+
return { providerName: credential.name, status: 'error', error: errorMessage };
378+
}
379+
}

src/cli/operations/identity/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ export {
44
setTokenVaultKmsKey,
55
updateApiKeyProvider,
66
} from './api-key-credential-provider';
7+
export {
8+
createOAuth2Provider,
9+
getOAuth2Provider,
10+
oAuth2ProviderExists,
11+
updateOAuth2Provider,
12+
type OAuth2ProviderParams,
13+
type OAuth2ProviderResult,
14+
} from './oauth2-credential-provider';
715
export {
816
computeDefaultCredentialEnvVarName,
917
resolveCredentialStrategy,

0 commit comments

Comments
 (0)