Skip to content

Commit a717414

Browse files
committed
feat: add OAuth credential provider creation during deploy
1 parent 0e96f8e commit a717414

File tree

7 files changed

+390
-15
lines changed

7 files changed

+390
-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
@@ -4,6 +4,11 @@ import { getCredentialProvider } from '../../aws';
44
import { isNoCredentialsError } from '../../errors';
55
import { apiKeyProviderExists, createApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider } from '../identity';
66
import { computeDefaultCredentialEnvVarName } from '../identity/create-identity';
7+
import {
8+
createOAuth2Provider,
9+
oAuth2ProviderExists,
10+
updateOAuth2Provider,
11+
} from '../identity/oauth2-credential-provider';
712
import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control';
813
import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms';
914

@@ -222,7 +227,7 @@ export async function getMissingCredentials(
222227
}
223228

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

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

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)