From 4905d888e237326c6c64ca7244ba56a2e180f018 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 29 Jul 2026 10:51:07 -0400 Subject: [PATCH 1/3] feat: add OAuth2 provider support to identity --- src/core/identity.tsx | 58 ++++ .../create/index.tsx | 4 +- .../api-key-credential-provider/parser.tsx | 42 --- .../update/index.tsx | 4 +- src/handlers/identity/identity.test.tsx | 1 + src/handlers/identity/index.tsx | 4 +- ...ntialProviderCommand.15e3501198437ba9.json | 18 ++ ...ntialProviderCommand.5ccb325cf76fc258.json | 18 ++ ...ntialProviderCommand.1def95784910b125.json | 1 + ...ntialProviderCommand.450abe7018948e2d.json | 1 + ...ntialProviderCommand.1def95784910b125.json | 25 ++ ...ntialProviderCommand.32c5ac3d87a14dd1.json | 6 + ...tialProvidersCommand.23f97c9dcdd6350b.json | 26 ++ ...tialProvidersCommand.7d2e22c637f6b633.json | 16 + ...tialProvidersCommand.d4ab0333e45c9f96.json | 15 + ...ntialProviderCommand.16483b6b51f663af.json | 25 ++ .../__fixtures__/create-2.golden.json | 18 ++ .../__fixtures__/create.golden.json | 18 ++ .../__fixtures__/delete-2.golden.json | 1 + .../__fixtures__/delete.golden.json | 1 + .../__fixtures__/get.golden.json | 21 ++ .../__fixtures__/list-page-1.golden.json | 12 + .../__fixtures__/list-page-2.golden.json | 11 + .../__fixtures__/list.golden.json | 18 ++ .../__fixtures__/update.golden.json | 21 ++ .../create/index.tsx | 149 +++++++++ .../delete/index.tsx | 23 ++ .../oauth2-credential-provider/get/index.tsx | 23 ++ .../oauth2-credential-provider/index.tsx | 19 ++ .../oauth2-credential-provider/list/index.tsx | 26 ++ .../oauth2.test.tsx | 303 ++++++++++++++++++ .../update/index.tsx | 161 ++++++++++ .../parser.test.tsx | 26 +- src/handlers/identity/parser.tsx | 34 ++ src/handlers/identity/types.tsx | 31 ++ src/testing/TestCoreClient.tsx | 50 +++ 36 files changed, 1176 insertions(+), 54 deletions(-) delete mode 100644 src/handlers/identity/api-key-credential-provider/parser.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.1def95784910b125.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.450abe7018948e2d.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.32c5ac3d87a14dd1.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/delete-2.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/delete.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json create mode 100644 src/handlers/identity/oauth2-credential-provider/create/index.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/delete/index.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/get/index.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/index.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/list/index.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx create mode 100644 src/handlers/identity/oauth2-credential-provider/update/index.tsx rename src/handlers/identity/{api-key-credential-provider => }/parser.test.tsx (64%) create mode 100644 src/handlers/identity/parser.tsx diff --git a/src/core/identity.tsx b/src/core/identity.tsx index 0c50f7602..c52f1c3d3 100644 --- a/src/core/identity.tsx +++ b/src/core/identity.tsx @@ -1,19 +1,31 @@ import { CreateApiKeyCredentialProviderCommand, + CreateOauth2CredentialProviderCommand, DeleteApiKeyCredentialProviderCommand, + DeleteOauth2CredentialProviderCommand, GetApiKeyCredentialProviderCommand, + GetOauth2CredentialProviderCommand, ListApiKeyCredentialProvidersCommand, + ListOauth2CredentialProvidersCommand, UpdateApiKeyCredentialProviderCommand, + UpdateOauth2CredentialProviderCommand, type CreateApiKeyCredentialProviderResponse, + type CreateOauth2CredentialProviderResponse, type DeleteApiKeyCredentialProviderResponse, + type DeleteOauth2CredentialProviderResponse, type GetApiKeyCredentialProviderResponse, + type GetOauth2CredentialProviderResponse, type ListApiKeyCredentialProvidersResponse, + type ListOauth2CredentialProvidersResponse, type UpdateApiKeyCredentialProviderResponse, + type UpdateOauth2CredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreIdentityClient, CreateApiKeyCredentialProviderInput, + CreateOauth2CredentialProviderInput, UpdateApiKeyCredentialProviderInput, + UpdateOauth2CredentialProviderInput, } from "../handlers/identity/types"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; @@ -66,4 +78,50 @@ export class IdentityClient implements CoreIdentityClient { .control(toClientConfig(options)) .send(new DeleteApiKeyCredentialProviderCommand({ name })); } + + async createOauth2CredentialProvider( + input: CreateOauth2CredentialProviderInput, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new CreateOauth2CredentialProviderCommand(input)); + } + + async getOauth2CredentialProvider( + name: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new GetOauth2CredentialProviderCommand({ name })); + } + + async listOauth2CredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new ListOauth2CredentialProvidersCommand({ nextToken, maxResults })); + } + + async updateOauth2CredentialProvider( + input: UpdateOauth2CredentialProviderInput, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new UpdateOauth2CredentialProviderCommand(input)); + } + + async deleteOauth2CredentialProvider( + name: string, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeleteOauth2CredentialProviderCommand({ name })); + } } diff --git a/src/handlers/identity/api-key-credential-provider/create/index.tsx b/src/handlers/identity/api-key-credential-provider/create/index.tsx index 51f9d60df..1b8d5dcbf 100644 --- a/src/handlers/identity/api-key-credential-provider/create/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/create/index.tsx @@ -6,7 +6,7 @@ import type { Core } from "../../../types"; import type { AppIO } from "../../../../io"; import { coreOptsFromCtx, parseTags } from "../../../utils"; import { SourceResolver } from "../../../../io"; -import { parseSecretReference } from "../parser"; +import { parseSecretReference } from "../../parser"; export const createCreateApiKeyCredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ @@ -46,7 +46,7 @@ export const createCreateApiKeyCredentialProviderHandler = (core: Core, io: AppI const resolver = new SourceResolver({ stdin: io.stdin }); const apiKey = await resolver.resolveText("api-key", flags["api-key"]); const apiKeySecretConfig = hasSecretRef - ? parseSecretReference(flags["api-key-secret-reference"]!) + ? parseSecretReference("api-key-secret-reference", flags["api-key-secret-reference"]!) : undefined; const apiKeySecretSource = hasApiKey ? "MANAGED" : "EXTERNAL"; const tags = parseTags(flags.tags); diff --git a/src/handlers/identity/api-key-credential-provider/parser.tsx b/src/handlers/identity/api-key-credential-provider/parser.tsx deleted file mode 100644 index 729c4b139..000000000 --- a/src/handlers/identity/api-key-credential-provider/parser.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { InputValidationError } from "../../../errors"; - -// Requires exactly { secretId, jsonKey } with non-empty string values. -export function parseSecretReference(raw: string): { secretId: string; jsonKey: string } { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error) { - throw new InputValidationError( - `Invalid JSON for --api-key-secret-reference: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new InputValidationError( - '--api-key-secret-reference must be a JSON object with "secretId" and "jsonKey"', - ); - } - - const obj = parsed as Record; - const allowedKeys = new Set(["secretId", "jsonKey"]); - const unexpected = Object.keys(obj).filter((k) => !allowedKeys.has(k)); - if (unexpected.length > 0) { - throw new InputValidationError( - `--api-key-secret-reference contains unexpected fields: ${unexpected.join(", ")}`, - ); - } - - if (typeof obj.secretId !== "string" || obj.secretId.length === 0) { - throw new InputValidationError( - '--api-key-secret-reference requires a non-empty "secretId" string', - ); - } - if (typeof obj.jsonKey !== "string" || obj.jsonKey.length === 0) { - throw new InputValidationError( - '--api-key-secret-reference requires a non-empty "jsonKey" string', - ); - } - - return { secretId: obj.secretId, jsonKey: obj.jsonKey }; -} diff --git a/src/handlers/identity/api-key-credential-provider/update/index.tsx b/src/handlers/identity/api-key-credential-provider/update/index.tsx index 05ed7a170..bb50b5802 100644 --- a/src/handlers/identity/api-key-credential-provider/update/index.tsx +++ b/src/handlers/identity/api-key-credential-provider/update/index.tsx @@ -6,7 +6,7 @@ import type { Core } from "../../../types"; import type { AppIO } from "../../../../io"; import { coreOptsFromCtx } from "../../../utils"; import { SourceResolver } from "../../../../io"; -import { parseSecretReference } from "../parser"; +import { parseSecretReference } from "../../parser"; export const createUpdateApiKeyCredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ @@ -59,7 +59,7 @@ export const createUpdateApiKeyCredentialProviderHandler = (core: Core, io: AppI const resolver = new SourceResolver({ stdin: io.stdin }); const apiKey = await resolver.resolveText("api-key", flags["api-key"]); const apiKeySecretConfig = hasSecretRef - ? parseSecretReference(flags["api-key-secret-reference"]!) + ? parseSecretReference("api-key-secret-reference", flags["api-key-secret-reference"]!) : undefined; ctx.require(JsonRendererKey).renderJson( diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index 7c04997af..14066a2df 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -53,6 +53,7 @@ describe("identity command hierarchy", () => { expect(identity?.children().map((child) => child.name())).toEqual([ "api-key-credential-provider", + "oauth2-credential-provider", ]); expect( identity diff --git a/src/handlers/identity/index.tsx b/src/handlers/identity/index.tsx index 664967752..eed7d0ecd 100644 --- a/src/handlers/identity/index.tsx +++ b/src/handlers/identity/index.tsx @@ -2,10 +2,12 @@ import { Router } from "../../router"; import type { AppIO } from "../../io"; import type { Core } from "../types"; import { createApiKeyCredentialProviderHandler } from "./api-key-credential-provider"; +import { createOauth2CredentialProviderHandler } from "./oauth2-credential-provider"; import { createHelpDefault } from "../help"; export function createIdentityHandler(core: Core, io: AppIO): Router { return new Router("identity", "manage AgentCore Identity resources") .default(createHelpDefault(io)) - .handler(createApiKeyCredentialProviderHandler(core, io)); + .handler(createApiKeyCredentialProviderHandler(core, io)) + .handler(createOauth2CredentialProviderHandler(core, io)); } diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json new file mode 100644 index 000000000..6a4e9d0ca --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json @@ -0,0 +1,18 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-f2d9252c-Wver51" + }, + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/060bb019-777a-45c8-a7dc-d540c1ac8ff9", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id-2" + } + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json new file mode 100644 index 000000000..25f2c38e6 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json @@ -0,0 +1,18 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id" + } + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.1def95784910b125.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.1def95784910b125.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.1def95784910b125.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.450abe7018948e2d.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.450abe7018948e2d.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/DeleteOauth2CredentialProviderCommand.450abe7018948e2d.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json new file mode 100644 index 000000000..cdba1e896 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json @@ -0,0 +1,25 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id" + } + }, + "createdTime": { + "$date": "2026-07-29T14:48:42.019Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:42.019Z" + }, + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.32c5ac3d87a14dd1.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.32c5ac3d87a14dd1.json new file mode 100644 index 000000000..d13baf3f1 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.32c5ac3d87a14dd1.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "CredentialProvider not found" + } +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json new file mode 100644 index 000000000..5d644dc71 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json @@ -0,0 +1,26 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "createdTime": { + "$date": "2026-07-29T14:48:42.019Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:42.019Z" + } + }, + { + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "createdTime": { + "$date": "2026-07-29T14:48:42.317Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:42.317Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json new file mode 100644 index 000000000..ff12bce4f --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json @@ -0,0 +1,16 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "createdTime": { + "$date": "2026-07-29T14:48:42.019Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:42.019Z" + } + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjg1MTk3NzA4Njg3I09BVVRIMiIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlNLIjp7InZhbHVlU3RyIjoiYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQ1JFRFNfUFJPVklERVIjNjg1MTk3NzA4Njg3I2RlZmF1bHQjYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json new file mode 100644 index 000000000..d4f26bc75 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json @@ -0,0 +1,15 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "createdTime": { + "$date": "2026-07-29T14:48:42.317Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:42.317Z" + } + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json new file mode 100644 index 000000000..39dfe199f --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json @@ -0,0 +1,25 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "updated-client-id" + } + }, + "createdTime": { + "$date": "2026-07-29T14:48:42.019Z" + }, + "lastUpdatedTime": { + "$date": "2026-07-29T14:48:43.091Z" + }, + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json new file mode 100644 index 000000000..6a4e9d0ca --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json @@ -0,0 +1,18 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-f2d9252c-Wver51" + }, + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/060bb019-777a-45c8-a7dc-d540c1ac8ff9", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id-2" + } + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json new file mode 100644 index 000000000..25f2c38e6 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json @@ -0,0 +1,18 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id" + } + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete-2.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete-2.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete-2.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete.golden.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/delete.golden.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json new file mode 100644 index 000000000..1ca35f056 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json @@ -0,0 +1,21 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "fixture-client-id" + } + }, + "createdTime": "2026-07-29T14:48:42.019Z", + "lastUpdatedTime": "2026-07-29T14:48:42.019Z", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json new file mode 100644 index 000000000..5ba63b02c --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json @@ -0,0 +1,12 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "createdTime": "2026-07-29T14:48:42.019Z", + "lastUpdatedTime": "2026-07-29T14:48:42.019Z" + } + ], + "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjg1MTk3NzA4Njg3I09BVVRIMiIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlNLIjp7InZhbHVlU3RyIjoiYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQ1JFRFNfUFJPVklERVIjNjg1MTk3NzA4Njg3I2RlZmF1bHQjYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json new file mode 100644 index 000000000..a61a3ecc0 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json @@ -0,0 +1,11 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "createdTime": "2026-07-29T14:48:42.317Z", + "lastUpdatedTime": "2026-07-29T14:48:42.317Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json new file mode 100644 index 000000000..20f442759 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json @@ -0,0 +1,18 @@ +{ + "credentialProviders": [ + { + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "createdTime": "2026-07-29T14:48:42.019Z", + "lastUpdatedTime": "2026-07-29T14:48:42.019Z" + }, + { + "name": "agentcore-cli-oauth2-fixture-2", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", + "createdTime": "2026-07-29T14:48:42.317Z", + "lastUpdatedTime": "2026-07-29T14:48:42.317Z" + } + ] +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json new file mode 100644 index 000000000..82865c6dc --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json @@ -0,0 +1,21 @@ +{ + "clientSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + }, + "name": "agentcore-cli-oauth2-fixture", + "credentialProviderVendor": "CustomOauth2", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", + "oauth2ProviderConfigOutput": { + "customOauth2ProviderConfig": { + "oauthDiscovery": { + "discoveryUrl": "https://example.com/.well-known/openid-configuration" + }, + "clientId": "updated-client-id" + } + }, + "createdTime": "2026-07-29T14:48:42.019Z", + "lastUpdatedTime": "2026-07-29T14:48:43.091Z", + "clientSecretSource": "MANAGED", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/create/index.tsx b/src/handlers/identity/oauth2-credential-provider/create/index.tsx new file mode 100644 index 000000000..f27c60bdf --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/create/index.tsx @@ -0,0 +1,149 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import type { AppIO } from "../../../../io"; +import { coreOptsFromCtx, parseTags } from "../../../utils"; +import { SourceResolver } from "../../../../io"; +import { parseSecretReference } from "../../parser"; +import { parseJsonFlag } from "../../../utils"; + +export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create an OAuth2 credential provider", + flags: [ + flag("name", "the name of the OAuth2 credential provider", z.string().optional()), + flag("vendor", "the OAuth2 vendor (e.g. CustomOauth2, GithubOauth2)", z.string().optional()), + flag( + "client-secret", + "the client secret (inline, file://path, or -)", + z.string().optional(), + { + sensitive: true, + }, + ), + flag( + "client-secret-reference", + 'external secret reference JSON: {"secretId":"","jsonKey":""}', + z.string().optional(), + ), + flag("client-id", "OAuth2 client ID (guided Custom OAuth2)", z.string().optional()), + flag("discovery-url", "OAuth2 discovery URL (guided Custom OAuth2)", z.string().optional()), + flag( + "authorization-server-metadata", + "authorization server metadata JSON (guided Custom OAuth2)", + z.string().optional(), + ), + flag( + "provider-configuration", + "complete OAuth2 provider configuration JSON (alternative to guided flags)", + z.string().optional(), + ), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + if (!flags.vendor) { + throw new TypeError("required option '--vendor ' not specified"); + } + + const hasClientSecret = flags["client-secret"] !== undefined; + const hasSecretRef = flags["client-secret-reference"] !== undefined; + const hasProviderConfig = flags["provider-configuration"] !== undefined; + const hasGuidedFlags = + flags["client-id"] !== undefined || + flags["discovery-url"] !== undefined || + flags["authorization-server-metadata"] !== undefined; + + if (hasClientSecret && hasSecretRef) { + throw new TypeError("--client-secret and --client-secret-reference are mutually exclusive"); + } + if (!hasClientSecret && !hasSecretRef) { + throw new TypeError("either --client-secret or --client-secret-reference is required"); + } + if (hasGuidedFlags && flags.vendor !== "CustomOauth2") { + throw new TypeError( + "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", + ); + } + if (hasProviderConfig && hasGuidedFlags) { + throw new TypeError( + "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", + ); + } + if (flags["discovery-url"] && flags["authorization-server-metadata"]) { + throw new TypeError( + "--discovery-url and --authorization-server-metadata are mutually exclusive", + ); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); + + const clientSecretConfig = hasSecretRef + ? parseSecretReference("client-secret-reference", flags["client-secret-reference"]!) + : undefined; + + const clientSecretSource = hasClientSecret ? "MANAGED" : "EXTERNAL"; + + let oauth2ProviderConfigInput: Record; + + if (hasProviderConfig) { + const config = parseJsonFlag>( + "provider-configuration", + flags["provider-configuration"], + )!; + const configKey = Object.keys(config)[0]; + if (!configKey || typeof config[configKey] !== "object") { + throw new TypeError( + "--provider-configuration must contain a single vendor config object", + ); + } + const vendorConfig = config[configKey] as Record; + vendorConfig.clientSecret = clientSecret; + vendorConfig.clientSecretConfig = clientSecretConfig; + vendorConfig.clientSecretSource = clientSecretSource; + oauth2ProviderConfigInput = config; + } else { + const authServerMetadata = parseJsonFlag>( + "authorization-server-metadata", + flags["authorization-server-metadata"], + ); + + const oauthDiscovery: Record = {}; + if (flags["discovery-url"]) { + oauthDiscovery.discoveryUrl = flags["discovery-url"]; + } + if (authServerMetadata) { + oauthDiscovery.authorizationServerMetadata = authServerMetadata; + } + + oauth2ProviderConfigInput = { + customOauth2ProviderConfig: { + clientId: flags["client-id"], + clientSecret, + clientSecretConfig, + clientSecretSource, + ...(Object.keys(oauthDiscovery).length > 0 && { oauthDiscovery }), + }, + }; + } + + const tags = parseTags(flags.tags); + + ctx.require(JsonRendererKey).renderJson( + await core.identity.createOauth2CredentialProvider( + { + name: flags.name, + credentialProviderVendor: flags.vendor as any, + oauth2ProviderConfigInput: oauth2ProviderConfigInput as any, + tags, + }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/oauth2-credential-provider/delete/index.tsx b/src/handlers/identity/oauth2-credential-provider/delete/index.tsx new file mode 100644 index 000000000..241a5e1f7 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/delete/index.tsx @@ -0,0 +1,23 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeleteOauth2CredentialProviderHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete an OAuth2 credential provider", + flags: [flag("name", "the name of the OAuth2 credential provider", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.deleteOauth2CredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/oauth2-credential-provider/get/index.tsx b/src/handlers/identity/oauth2-credential-provider/get/index.tsx new file mode 100644 index 000000000..5f4dd806a --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/get/index.tsx @@ -0,0 +1,23 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createGetOauth2CredentialProviderHandler = (core: Core) => + createHandler({ + name: "get", + description: "get an OAuth2 credential provider", + flags: [flag("name", "the name of the OAuth2 credential provider", z.string().optional())], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.getOauth2CredentialProvider(flags.name, coreOptsFromCtx(ctx)), + ); + }, + }); diff --git a/src/handlers/identity/oauth2-credential-provider/index.tsx b/src/handlers/identity/oauth2-credential-provider/index.tsx new file mode 100644 index 000000000..a312e2a63 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/index.tsx @@ -0,0 +1,19 @@ +import { Router } from "../../../router"; +import type { AppIO } from "../../../io"; +import type { Core } from "../../types"; +import { createHelpDefault } from "../../help"; +import { createCreateOauth2CredentialProviderHandler } from "./create"; +import { createDeleteOauth2CredentialProviderHandler } from "./delete"; +import { createGetOauth2CredentialProviderHandler } from "./get"; +import { createListOauth2CredentialProvidersHandler } from "./list"; +import { createUpdateOauth2CredentialProviderHandler } from "./update"; + +export function createOauth2CredentialProviderHandler(core: Core, io: AppIO): Router { + return new Router("oauth2-credential-provider", "manage OAuth2 credential providers") + .default(createHelpDefault(io)) + .handler(createCreateOauth2CredentialProviderHandler(core, io)) + .handler(createGetOauth2CredentialProviderHandler(core)) + .handler(createListOauth2CredentialProvidersHandler(core)) + .handler(createUpdateOauth2CredentialProviderHandler(core, io)) + .handler(createDeleteOauth2CredentialProviderHandler(core)); +} diff --git a/src/handlers/identity/oauth2-credential-provider/list/index.tsx b/src/handlers/identity/oauth2-credential-provider/list/index.tsx new file mode 100644 index 000000000..c827018a9 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/list/index.tsx @@ -0,0 +1,26 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createListOauth2CredentialProvidersHandler = (core: Core) => + createHandler({ + name: "list", + description: "list OAuth2 credential providers", + flags: [ + flag("next-token", "pagination token returned by a previous request", z.string().optional()), + flag("max-results", "maximum number of items to return", z.number().optional()), + ], + handle: async (ctx, flags) => { + ctx + .require(JsonRendererKey) + .renderJson( + await core.identity.listOauth2CredentialProviders( + flags["next-token"], + flags["max-results"], + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx new file mode 100644 index 000000000..05fb863e0 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx @@ -0,0 +1,303 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); + +// Record with RECORD=1 bun test src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx +// Neither fixture provider should exist before recording. The RECORD run creates +// both providers, exercises pagination (requires >=2), then deletes +const FIXTURE_PROVIDER_NAME = "agentcore-cli-oauth2-fixture"; +const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-oauth2-fixture-2"; +const MISSING_PROVIDER_NAME = "missing-oauth2-provider-000"; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +describe("oauth2-credential-provider command hierarchy", () => { + test("registers the oauth2-credential-provider command hierarchy", () => { + const root = createRootHandler(createFixtureCore(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const identity = root.children().find((child) => child.name() === "identity"); + const oauth2 = identity + ?.children() + .find((child) => child.name() === "oauth2-credential-provider"); + + expect(oauth2?.children().map((child) => child.name())).toEqual([ + "create", + "get", + "list", + "update", + "delete", + ]); + }); + + test("prints help for bare `identity oauth2-credential-provider` without an SDK call", async () => { + const stdout = await run(["identity", "oauth2-credential-provider"]); + + expect(stdout).toContain("Usage: agentcore identity oauth2-credential-provider"); + expect(stdout).toContain("Commands:"); + }); +}); + +describe("oauth2-credential-provider CRUDL", () => { + test("creates an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CustomOauth2", + "--client-id", + "fixture-client-id", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "fixture-secret", + ]); + + matchGolden(FIXTURES, "create.golden.json", stdout); + }); + + test("creates a second OAuth2 credential provider for pagination", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME_2, + "--vendor", + "CustomOauth2", + "--client-id", + "fixture-client-id-2", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "fixture-secret-2", + ]); + + matchGolden(FIXTURES, "create-2.golden.json", stdout); + }); + + test("gets an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "get", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "get.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("lists OAuth2 credential providers", async () => { + const stdout = await run(["identity", "oauth2-credential-provider", "list"]); + + matchGolden(FIXTURES, "list.golden.json", stdout); + expect(JSON.parse(stdout).credentialProviders).toBeArray(); + }); + + test("paginates OAuth2 credential provider list with --max-results and --next-token", async () => { + const firstPage = await run([ + "identity", + "oauth2-credential-provider", + "list", + "--max-results", + "1", + ]); + matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); + + const first = JSON.parse(firstPage); + expect(first.credentialProviders).toHaveLength(1); + expect(first.nextToken).toBeString(); + + const secondPage = await run([ + "identity", + "oauth2-credential-provider", + "list", + "--max-results", + "1", + "--next-token", + first.nextToken, + ]); + matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); + expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); + }); + + test("updates an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "update", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CustomOauth2", + "--client-id", + "updated-client-id", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "updated-secret", + ]); + + matchGolden(FIXTURES, "update.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("deletes the first OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "delete.golden.json", stdout); + }); + + test("deletes the second OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME_2, + ]); + + matchGolden(FIXTURES, "delete-2.golden.json", stdout); + }); +}); + +describe("oauth2-credential-provider flag validation", () => { + test.each([ + [ + "create --name only", + ["identity", "oauth2-credential-provider", "create", "--name", "x"], + /--vendor/, + ], + [ + "create --name + --vendor only", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "CustomOauth2", + ], + /--client-secret.*--client-secret-reference/, + ], + ["create bare", ["identity", "oauth2-credential-provider", "create"], /--name/], + ["get bare", ["identity", "oauth2-credential-provider", "get"], /--name/], + ["update bare", ["identity", "oauth2-credential-provider", "update"], /--name/], + ["delete bare", ["identity", "oauth2-credential-provider", "delete"], /--name/], + ] as const)("rejects missing required flags for `%s`", async (_label, args, message) => { + expect(run([...args])).rejects.toThrow(message); + }); + + test.each([ + [ + "create: --client-secret with --client-secret-reference", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "CustomOauth2", + "--client-secret", + "s", + "--client-secret-reference", + '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"k"}', + ], + /mutually exclusive/, + ], + [ + "create: --provider-configuration with guided flags", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "CustomOauth2", + "--client-secret", + "s", + "--client-id", + "c", + "--provider-configuration", + '{"customOauth2ProviderConfig":{"clientId":"c"}}', + ], + /mutually exclusive/, + ], + [ + "create: --discovery-url with --authorization-server-metadata", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "CustomOauth2", + "--client-secret", + "s", + "--discovery-url", + "https://example.com", + "--authorization-server-metadata", + '{"issuer":"https://example.com"}', + ], + /mutually exclusive/, + ], + ] as const)("rejects mutually exclusive flags for `%s`", async (_label, args, message) => { + expect(run([...args])).rejects.toThrow(message); + }); + + test("propagates ResourceNotFoundException from get", async () => { + await expect( + run(["identity", "oauth2-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), + ).rejects.toMatchObject({ + name: "ResourceNotFoundException", + }); + }); +}); diff --git a/src/handlers/identity/oauth2-credential-provider/update/index.tsx b/src/handlers/identity/oauth2-credential-provider/update/index.tsx new file mode 100644 index 000000000..73ab86ef1 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/update/index.tsx @@ -0,0 +1,161 @@ +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import type { AppIO } from "../../../../io"; +import { coreOptsFromCtx } from "../../../utils"; +import { SourceResolver } from "../../../../io"; +import { parseSecretReference } from "../../parser"; +import { parseJsonFlag } from "../../../utils"; + +export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update an OAuth2 credential provider", + flags: [ + flag("name", "the name of the OAuth2 credential provider", z.string().optional()), + flag("vendor", "the OAuth2 vendor", z.string().optional()), + flag( + "client-secret", + "the client secret (inline, file://path, or -)", + z.string().optional(), + { + sensitive: true, + }, + ), + flag( + "client-secret-reference", + 'external secret reference JSON: {"secretId":"","jsonKey":""}', + z.string().optional(), + ), + flag("client-id", "OAuth2 client ID (guided Custom OAuth2)", z.string().optional()), + flag("discovery-url", "OAuth2 discovery URL (guided Custom OAuth2)", z.string().optional()), + flag( + "authorization-server-metadata", + "authorization server metadata JSON (guided Custom OAuth2)", + z.string().optional(), + ), + flag( + "provider-configuration", + "complete OAuth2 provider configuration JSON (alternative to guided flags)", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new TypeError("required option '--name ' not specified"); + } + + const hasClientSecret = flags["client-secret"] !== undefined; + const hasSecretRef = flags["client-secret-reference"] !== undefined; + const hasProviderConfig = flags["provider-configuration"] !== undefined; + const hasGuidedFlags = + flags["client-id"] !== undefined || + flags["discovery-url"] !== undefined || + flags["authorization-server-metadata"] !== undefined; + + if (hasClientSecret && hasSecretRef) { + throw new TypeError("--client-secret and --client-secret-reference are mutually exclusive"); + } + if (!hasClientSecret && !hasSecretRef) { + throw new TypeError("either --client-secret or --client-secret-reference is required"); + } + if (hasProviderConfig && hasGuidedFlags) { + throw new TypeError( + "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", + ); + } + if (flags["discovery-url"] && flags["authorization-server-metadata"]) { + throw new TypeError( + "--discovery-url and --authorization-server-metadata are mutually exclusive", + ); + } + + const opts = coreOptsFromCtx(ctx); + const existing = await core.identity.getOauth2CredentialProvider(flags.name, opts); + + if (hasClientSecret && existing.clientSecretSource === "EXTERNAL") { + throw new TypeError( + "this provider uses an external secret; use --client-secret-reference to update it", + ); + } + if (hasSecretRef && existing.clientSecretSource === "MANAGED") { + throw new TypeError( + "this provider uses a managed secret; use --client-secret to update it", + ); + } + + const vendor = flags.vendor ?? existing.credentialProviderVendor; + if (!vendor) { + throw new TypeError("required option '--vendor ' not specified"); + } + if (hasGuidedFlags && vendor !== "CustomOauth2") { + throw new TypeError( + "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", + ); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); + + const clientSecretConfig = hasSecretRef + ? parseSecretReference("client-secret-reference", flags["client-secret-reference"]!) + : undefined; + + const clientSecretSource = existing.clientSecretSource; + + let oauth2ProviderConfigInput: Record; + + if (hasProviderConfig) { + const config = parseJsonFlag>( + "provider-configuration", + flags["provider-configuration"], + )!; + const configKey = Object.keys(config)[0]; + if (!configKey || typeof config[configKey] !== "object") { + throw new TypeError( + "--provider-configuration must contain a single vendor config object", + ); + } + const vendorConfig = config[configKey] as Record; + vendorConfig.clientSecret = clientSecret; + vendorConfig.clientSecretConfig = clientSecretConfig; + vendorConfig.clientSecretSource = clientSecretSource; + oauth2ProviderConfigInput = config; + } else { + const authServerMetadata = parseJsonFlag>( + "authorization-server-metadata", + flags["authorization-server-metadata"], + ); + + const oauthDiscovery: Record = {}; + if (flags["discovery-url"]) { + oauthDiscovery.discoveryUrl = flags["discovery-url"]; + } + if (authServerMetadata) { + oauthDiscovery.authorizationServerMetadata = authServerMetadata; + } + + oauth2ProviderConfigInput = { + customOauth2ProviderConfig: { + clientId: flags["client-id"], + clientSecret, + clientSecretConfig, + clientSecretSource, + ...(Object.keys(oauthDiscovery).length > 0 && { oauthDiscovery }), + }, + }; + } + + ctx.require(JsonRendererKey).renderJson( + await core.identity.updateOauth2CredentialProvider( + { + name: flags.name, + credentialProviderVendor: vendor as any, + oauth2ProviderConfigInput: oauth2ProviderConfigInput as any, + }, + opts, + ), + ); + }, + }); diff --git a/src/handlers/identity/api-key-credential-provider/parser.test.tsx b/src/handlers/identity/parser.test.tsx similarity index 64% rename from src/handlers/identity/api-key-credential-provider/parser.test.tsx rename to src/handlers/identity/parser.test.tsx index 29d7b204b..a7aee5778 100644 --- a/src/handlers/identity/api-key-credential-provider/parser.test.tsx +++ b/src/handlers/identity/parser.test.tsx @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { parseSecretReference } from "./parser"; +const FLAG = "test-secret-reference"; + describe("parseSecretReference", () => { test("parses a valid secret reference", () => { const result = parseSecretReference( + FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:my-secret","jsonKey":"apiKey"}', ); expect(result).toEqual({ @@ -13,29 +16,31 @@ describe("parseSecretReference", () => { }); test("rejects invalid JSON", () => { - expect(() => parseSecretReference("{not json}")).toThrow("Invalid JSON"); + expect(() => parseSecretReference(FLAG, "{not json}")).toThrow("Invalid JSON"); }); test("rejects non-object input", () => { - expect(() => parseSecretReference('"just a string"')).toThrow("must be a JSON object"); + expect(() => parseSecretReference(FLAG, '"just a string"')).toThrow("must be a JSON object"); }); test("rejects array input", () => { - expect(() => parseSecretReference("[]")).toThrow("must be a JSON object"); + expect(() => parseSecretReference(FLAG, "[]")).toThrow("must be a JSON object"); }); test("rejects missing secretId", () => { - expect(() => parseSecretReference('{"jsonKey":"apiKey"}')).toThrow('non-empty "secretId"'); + expect(() => parseSecretReference(FLAG, '{"jsonKey":"apiKey"}')).toThrow( + 'non-empty "secretId"', + ); }); test("rejects missing jsonKey", () => { expect(() => - parseSecretReference('{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s"}'), + parseSecretReference(FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s"}'), ).toThrow('non-empty "jsonKey"'); }); test("rejects empty secretId", () => { - expect(() => parseSecretReference('{"secretId":"","jsonKey":"apiKey"}')).toThrow( + expect(() => parseSecretReference(FLAG, '{"secretId":"","jsonKey":"apiKey"}')).toThrow( 'non-empty "secretId"', ); }); @@ -43,6 +48,7 @@ describe("parseSecretReference", () => { test("rejects empty jsonKey", () => { expect(() => parseSecretReference( + FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":""}', ), ).toThrow('non-empty "jsonKey"'); @@ -51,13 +57,14 @@ describe("parseSecretReference", () => { test("rejects unexpected fields", () => { expect(() => parseSecretReference( + FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey","extra":"bad"}', ), ).toThrow("unexpected fields: extra"); }); test("rejects non-string secretId", () => { - expect(() => parseSecretReference('{"secretId":123,"jsonKey":"apiKey"}')).toThrow( + expect(() => parseSecretReference(FLAG, '{"secretId":123,"jsonKey":"apiKey"}')).toThrow( 'non-empty "secretId"', ); }); @@ -65,8 +72,13 @@ describe("parseSecretReference", () => { test("rejects non-string jsonKey", () => { expect(() => parseSecretReference( + FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":true}', ), ).toThrow('non-empty "jsonKey"'); }); + + test("includes flag name in error messages", () => { + expect(() => parseSecretReference("my-flag", "{bad}")).toThrow("--my-flag"); + }); }); diff --git a/src/handlers/identity/parser.tsx b/src/handlers/identity/parser.tsx new file mode 100644 index 000000000..3a10348ed --- /dev/null +++ b/src/handlers/identity/parser.tsx @@ -0,0 +1,34 @@ +// Requires exactly { secretId, jsonKey } with non-empty string values. +export function parseSecretReference( + flagName: string, + raw: string, +): { secretId: string; jsonKey: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new TypeError( + `Invalid JSON for --${flagName}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new TypeError(`--${flagName} must be a JSON object with "secretId" and "jsonKey"`); + } + + const obj = parsed as Record; + const allowedKeys = new Set(["secretId", "jsonKey"]); + const unexpected = Object.keys(obj).filter((k) => !allowedKeys.has(k)); + if (unexpected.length > 0) { + throw new TypeError(`--${flagName} contains unexpected fields: ${unexpected.join(", ")}`); + } + + if (typeof obj.secretId !== "string" || obj.secretId.length === 0) { + throw new TypeError(`--${flagName} requires a non-empty "secretId" string`); + } + if (typeof obj.jsonKey !== "string" || obj.jsonKey.length === 0) { + throw new TypeError(`--${flagName} requires a non-empty "jsonKey" string`); + } + + return { secretId: obj.secretId, jsonKey: obj.jsonKey }; +} diff --git a/src/handlers/identity/types.tsx b/src/handlers/identity/types.tsx index 7c11c75a1..6bf0ce8a0 100644 --- a/src/handlers/identity/types.tsx +++ b/src/handlers/identity/types.tsx @@ -1,16 +1,25 @@ import type { CreateApiKeyCredentialProviderRequest, CreateApiKeyCredentialProviderResponse, + CreateOauth2CredentialProviderRequest, + CreateOauth2CredentialProviderResponse, DeleteApiKeyCredentialProviderResponse, + DeleteOauth2CredentialProviderResponse, GetApiKeyCredentialProviderResponse, + GetOauth2CredentialProviderResponse, ListApiKeyCredentialProvidersResponse, + ListOauth2CredentialProvidersResponse, UpdateApiKeyCredentialProviderRequest, UpdateApiKeyCredentialProviderResponse, + UpdateOauth2CredentialProviderRequest, + UpdateOauth2CredentialProviderResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { CoreOptions } from "../../core/types"; export type CreateApiKeyCredentialProviderInput = CreateApiKeyCredentialProviderRequest; export type UpdateApiKeyCredentialProviderInput = UpdateApiKeyCredentialProviderRequest; +export type CreateOauth2CredentialProviderInput = CreateOauth2CredentialProviderRequest; +export type UpdateOauth2CredentialProviderInput = UpdateOauth2CredentialProviderRequest; export interface CoreIdentityClient { createApiKeyCredentialProvider( @@ -34,4 +43,26 @@ export interface CoreIdentityClient { name: string, options: CoreOptions, ): Promise; + + createOauth2CredentialProvider( + input: CreateOauth2CredentialProviderInput, + options: CoreOptions, + ): Promise; + getOauth2CredentialProvider( + name: string, + options: CoreOptions, + ): Promise; + listOauth2CredentialProviders( + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, + ): Promise; + updateOauth2CredentialProvider( + input: UpdateOauth2CredentialProviderInput, + options: CoreOptions, + ): Promise; + deleteOauth2CredentialProvider( + name: string, + options: CoreOptions, + ): Promise; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 7426ea9e1..0307eb84d 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -1,9 +1,11 @@ import type { CreateApiKeyCredentialProviderResponse, + CreateOauth2CredentialProviderResponse, CreateHarnessEndpointRequest, CreateHarnessEndpointResponse, CreateHarnessResponse, DeleteApiKeyCredentialProviderResponse, + DeleteOauth2CredentialProviderResponse, DeleteHarnessEndpointRequest, DeleteHarnessEndpointResponse, DeleteHarnessRequest, @@ -12,12 +14,14 @@ import type { GetGatewayRuleResponse, GetGatewayTargetResponse, GetApiKeyCredentialProviderResponse, + GetOauth2CredentialProviderResponse, GetHarnessResponse, GetHarnessEndpointResponse, GetAgentRuntimeEndpointResponse, GetAgentRuntimeResponse, GetMemoryOutput, ListApiKeyCredentialProvidersResponse, + ListOauth2CredentialProvidersResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -41,6 +45,7 @@ import type { UpdateEvaluatorResponse, UpdateOnlineEvaluationConfigResponse, UpdateApiKeyCredentialProviderResponse, + UpdateOauth2CredentialProviderResponse, UpdateHarnessEndpointRequest, UpdateHarnessEndpointResponse, UpdateHarnessRequest, @@ -72,7 +77,9 @@ import type { CoreGatewayClient } from "../handlers/gateway/types"; import type { CoreIdentityClient, CreateApiKeyCredentialProviderInput, + CreateOauth2CredentialProviderInput, UpdateApiKeyCredentialProviderInput, + UpdateOauth2CredentialProviderInput, } from "../handlers/identity/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; import type { @@ -153,6 +160,13 @@ const DEFAULT_GET_GATEWAY_TARGET_RESPONSE = {} as GetGatewayTargetResponse; const DEFAULT_LIST_GATEWAY_TARGETS_RESPONSE: ListGatewayTargetsResponse = { items: [] }; const DEFAULT_GET_GATEWAY_RULE_RESPONSE = {} as GetGatewayRuleResponse; const DEFAULT_LIST_GATEWAY_RULES_RESPONSE: ListGatewayRulesResponse = { gatewayRules: [] }; +const DEFAULT_CREATE_OAUTH2_RESPONSE = {} as CreateOauth2CredentialProviderResponse; +const DEFAULT_GET_OAUTH2_RESPONSE = {} as GetOauth2CredentialProviderResponse; +const DEFAULT_LIST_OAUTH2_RESPONSE: ListOauth2CredentialProvidersResponse = { + credentialProviders: [], +}; +const DEFAULT_UPDATE_OAUTH2_RESPONSE = {} as UpdateOauth2CredentialProviderResponse; +const DEFAULT_DELETE_OAUTH2_RESPONSE = {} as DeleteOauth2CredentialProviderResponse; const DEFAULT_GET_RUNTIME_RESPONSE = {} as GetAgentRuntimeResponse; const DEFAULT_GET_RUNTIME_ENDPOINT_RESPONSE = {} as GetAgentRuntimeEndpointResponse; const DEFAULT_LIST_RUNTIMES_RESPONSE: ListAgentRuntimesResponse = { agentRuntimes: [] }; @@ -942,6 +956,42 @@ class TestIdentityClient implements CoreIdentityClient { ): Promise { return DEFAULT_DELETE_API_KEY_RESPONSE; } + + async createOauth2CredentialProvider( + _input: CreateOauth2CredentialProviderInput, + _options: CoreOptions, + ): Promise { + return DEFAULT_CREATE_OAUTH2_RESPONSE; + } + + async getOauth2CredentialProvider( + _name: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_GET_OAUTH2_RESPONSE; + } + + async listOauth2CredentialProviders( + _nextToken: string | undefined, + _maxResults: number | undefined, + _options: CoreOptions, + ): Promise { + return DEFAULT_LIST_OAUTH2_RESPONSE; + } + + async updateOauth2CredentialProvider( + _input: UpdateOauth2CredentialProviderInput, + _options: CoreOptions, + ): Promise { + return DEFAULT_UPDATE_OAUTH2_RESPONSE; + } + + async deleteOauth2CredentialProvider( + _name: string, + _options: CoreOptions, + ): Promise { + return DEFAULT_DELETE_OAUTH2_RESPONSE; + } } // TestEvalClient is the eval sub-client of TestCoreClient. From f090aafffbbd4a77f23efaba1de8e98bb9eba231 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 3 Aug 2026 11:36:24 -0400 Subject: [PATCH 2/3] fix: mark provider config as sensitive + use modeled errors --- src/handlers/identity/identity.test.tsx | 4 +- .../create/index.tsx | 49 +++++++++++++------ .../delete/index.tsx | 3 +- .../oauth2-credential-provider/get/index.tsx | 3 +- .../oauth2.test.tsx | 37 ++++++++++++++ .../update/index.tsx | 49 +++++++++++++------ src/handlers/identity/parser.test.tsx | 25 +++++----- src/handlers/identity/parser.tsx | 37 +++++++------- 8 files changed, 143 insertions(+), 64 deletions(-) diff --git a/src/handlers/identity/identity.test.tsx b/src/handlers/identity/identity.test.tsx index 14066a2df..eb81613f0 100644 --- a/src/handlers/identity/identity.test.tsx +++ b/src/handlers/identity/identity.test.tsx @@ -241,7 +241,7 @@ describe("api-key-credential-provider CRUDL", () => { "--api-key-secret-reference", '{"jsonKey":"apiKey"}', ], - /non-empty "secretId"/, + /--api-key-secret-reference/, ], [ "create: --api-key-secret-reference with unexpected field", @@ -254,7 +254,7 @@ describe("api-key-credential-provider CRUDL", () => { "--api-key-secret-reference", '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey","extra":"bad"}', ], - /unexpected fields/, + /--api-key-secret-reference/, ], [ "update: --api-key with --api-key-secret-reference", diff --git a/src/handlers/identity/oauth2-credential-provider/create/index.tsx b/src/handlers/identity/oauth2-credential-provider/create/index.tsx index f27c60bdf..0dee2bb1f 100644 --- a/src/handlers/identity/oauth2-credential-provider/create/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/create/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -39,46 +40,64 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI "provider-configuration", "complete OAuth2 provider configuration JSON (alternative to guided flags)", z.string().optional(), + { sensitive: true }, ), flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), ], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } if (!flags.vendor) { - throw new TypeError("required option '--vendor ' not specified"); + throw new InputValidationError("required option '--vendor ' not specified"); } + const isCustomVendor = flags.vendor === "CustomOauth2"; const hasClientSecret = flags["client-secret"] !== undefined; const hasSecretRef = flags["client-secret-reference"] !== undefined; const hasProviderConfig = flags["provider-configuration"] !== undefined; + const hasDiscoveryUrl = flags["discovery-url"] !== undefined; + const hasAuthServerMetadata = flags["authorization-server-metadata"] !== undefined; const hasGuidedFlags = - flags["client-id"] !== undefined || - flags["discovery-url"] !== undefined || - flags["authorization-server-metadata"] !== undefined; + flags["client-id"] !== undefined || hasDiscoveryUrl || hasAuthServerMetadata; if (hasClientSecret && hasSecretRef) { - throw new TypeError("--client-secret and --client-secret-reference are mutually exclusive"); + throw new InputValidationError( + "--client-secret and --client-secret-reference are mutually exclusive", + ); } if (!hasClientSecret && !hasSecretRef) { - throw new TypeError("either --client-secret or --client-secret-reference is required"); - } - if (hasGuidedFlags && flags.vendor !== "CustomOauth2") { - throw new TypeError( - "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", + throw new InputValidationError( + "either --client-secret or --client-secret-reference is required", ); } if (hasProviderConfig && hasGuidedFlags) { - throw new TypeError( + throw new InputValidationError( "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", ); } - if (flags["discovery-url"] && flags["authorization-server-metadata"]) { - throw new TypeError( + if (hasGuidedFlags && !isCustomVendor) { + throw new InputValidationError( + "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", + ); + } + // non-custom vendors must supply a complete provider-configuration + if (!isCustomVendor && !hasProviderConfig) { + throw new InputValidationError( + `--provider-configuration is required for --vendor ${flags.vendor}; guided flags only support CustomOauth2`, + ); + } + if (hasDiscoveryUrl && hasAuthServerMetadata) { + throw new InputValidationError( "--discovery-url and --authorization-server-metadata are mutually exclusive", ); } + // the guided CustomOAuth2 path requires one discovery form + if (isCustomVendor && !hasProviderConfig && !hasDiscoveryUrl && !hasAuthServerMetadata) { + throw new InputValidationError( + "guided --vendor CustomOauth2 requires one of --discovery-url or --authorization-server-metadata", + ); + } const resolver = new SourceResolver({ stdin: io.stdin }); const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); @@ -98,7 +117,7 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI )!; const configKey = Object.keys(config)[0]; if (!configKey || typeof config[configKey] !== "object") { - throw new TypeError( + throw new InputValidationError( "--provider-configuration must contain a single vendor config object", ); } diff --git a/src/handlers/identity/oauth2-credential-provider/delete/index.tsx b/src/handlers/identity/oauth2-credential-provider/delete/index.tsx index 241a5e1f7..c0feb51ea 100644 --- a/src/handlers/identity/oauth2-credential-provider/delete/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/delete/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -11,7 +12,7 @@ export const createDeleteOauth2CredentialProviderHandler = (core: Core) => flags: [flag("name", "the name of the OAuth2 credential provider", z.string().optional())], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } ctx diff --git a/src/handlers/identity/oauth2-credential-provider/get/index.tsx b/src/handlers/identity/oauth2-credential-provider/get/index.tsx index 5f4dd806a..a2ad68ebf 100644 --- a/src/handlers/identity/oauth2-credential-provider/get/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/get/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -11,7 +12,7 @@ export const createGetOauth2CredentialProviderHandler = (core: Core) => flags: [flag("name", "the name of the OAuth2 credential provider", z.string().optional())], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } ctx diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx index 05fb863e0..03cb0d1c9 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx @@ -293,6 +293,43 @@ describe("oauth2-credential-provider flag validation", () => { expect(run([...args])).rejects.toThrow(message); }); + test.each([ + [ + "create: non-Custom vendor without --provider-configuration", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "GithubOauth2", + "--client-secret", + "s", + ], + /--provider-configuration is required for --vendor GithubOauth2/, + ], + [ + "create: guided Custom without --discovery-url or --authorization-server-metadata", + [ + "identity", + "oauth2-credential-provider", + "create", + "--name", + "x", + "--vendor", + "CustomOauth2", + "--client-secret", + "s", + "--client-id", + "c", + ], + /requires one of --discovery-url or --authorization-server-metadata/, + ], + ] as const)("enforces vendor/config-mode rules for `%s`", async (_label, args, message) => { + expect(run([...args])).rejects.toThrow(message); + }); + test("propagates ResourceNotFoundException from get", async () => { await expect( run(["identity", "oauth2-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), diff --git a/src/handlers/identity/oauth2-credential-provider/update/index.tsx b/src/handlers/identity/oauth2-credential-provider/update/index.tsx index 73ab86ef1..99c97d606 100644 --- a/src/handlers/identity/oauth2-credential-provider/update/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/update/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; import type { Core } from "../../../types"; @@ -39,34 +40,39 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI "provider-configuration", "complete OAuth2 provider configuration JSON (alternative to guided flags)", z.string().optional(), + { sensitive: true }, ), ], handle: async (ctx, flags) => { if (!flags.name) { - throw new TypeError("required option '--name ' not specified"); + throw new InputValidationError("required option '--name ' not specified"); } const hasClientSecret = flags["client-secret"] !== undefined; const hasSecretRef = flags["client-secret-reference"] !== undefined; const hasProviderConfig = flags["provider-configuration"] !== undefined; + const hasDiscoveryUrl = flags["discovery-url"] !== undefined; + const hasAuthServerMetadata = flags["authorization-server-metadata"] !== undefined; const hasGuidedFlags = - flags["client-id"] !== undefined || - flags["discovery-url"] !== undefined || - flags["authorization-server-metadata"] !== undefined; + flags["client-id"] !== undefined || hasDiscoveryUrl || hasAuthServerMetadata; if (hasClientSecret && hasSecretRef) { - throw new TypeError("--client-secret and --client-secret-reference are mutually exclusive"); + throw new InputValidationError( + "--client-secret and --client-secret-reference are mutually exclusive", + ); } if (!hasClientSecret && !hasSecretRef) { - throw new TypeError("either --client-secret or --client-secret-reference is required"); + throw new InputValidationError( + "either --client-secret or --client-secret-reference is required", + ); } if (hasProviderConfig && hasGuidedFlags) { - throw new TypeError( + throw new InputValidationError( "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", ); } - if (flags["discovery-url"] && flags["authorization-server-metadata"]) { - throw new TypeError( + if (hasDiscoveryUrl && hasAuthServerMetadata) { + throw new InputValidationError( "--discovery-url and --authorization-server-metadata are mutually exclusive", ); } @@ -75,25 +81,38 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI const existing = await core.identity.getOauth2CredentialProvider(flags.name, opts); if (hasClientSecret && existing.clientSecretSource === "EXTERNAL") { - throw new TypeError( + throw new InputValidationError( "this provider uses an external secret; use --client-secret-reference to update it", ); } if (hasSecretRef && existing.clientSecretSource === "MANAGED") { - throw new TypeError( + throw new InputValidationError( "this provider uses a managed secret; use --client-secret to update it", ); } const vendor = flags.vendor ?? existing.credentialProviderVendor; if (!vendor) { - throw new TypeError("required option '--vendor ' not specified"); + throw new InputValidationError("required option '--vendor ' not specified"); } - if (hasGuidedFlags && vendor !== "CustomOauth2") { - throw new TypeError( + const isCustomVendor = vendor === "CustomOauth2"; + if (hasGuidedFlags && !isCustomVendor) { + throw new InputValidationError( "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", ); } + // non-custom vendors must supply a complete provider-configuration + if (!isCustomVendor && !hasProviderConfig) { + throw new InputValidationError( + `--provider-configuration is required for --vendor ${vendor}; guided flags only support CustomOauth2`, + ); + } + // the guided CustomOAuth2 path requires one discovery form + if (isCustomVendor && !hasProviderConfig && !hasDiscoveryUrl && !hasAuthServerMetadata) { + throw new InputValidationError( + "guided --vendor CustomOauth2 requires one of --discovery-url or --authorization-server-metadata", + ); + } const resolver = new SourceResolver({ stdin: io.stdin }); const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); @@ -113,7 +132,7 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI )!; const configKey = Object.keys(config)[0]; if (!configKey || typeof config[configKey] !== "object") { - throw new TypeError( + throw new InputValidationError( "--provider-configuration must contain a single vendor config object", ); } diff --git a/src/handlers/identity/parser.test.tsx b/src/handlers/identity/parser.test.tsx index a7aee5778..e502e98e1 100644 --- a/src/handlers/identity/parser.test.tsx +++ b/src/handlers/identity/parser.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { InputValidationError } from "../../errors"; import { parseSecretReference } from "./parser"; const FLAG = "test-secret-reference"; @@ -15,33 +16,35 @@ describe("parseSecretReference", () => { }); }); + test("throws InputValidationError (not a generic error) on bad input", () => { + expect(() => parseSecretReference(FLAG, "{not json}")).toThrow(InputValidationError); + }); + test("rejects invalid JSON", () => { expect(() => parseSecretReference(FLAG, "{not json}")).toThrow("Invalid JSON"); }); test("rejects non-object input", () => { - expect(() => parseSecretReference(FLAG, '"just a string"')).toThrow("must be a JSON object"); + expect(() => parseSecretReference(FLAG, '"just a string"')).toThrow(`--${FLAG}`); }); test("rejects array input", () => { - expect(() => parseSecretReference(FLAG, "[]")).toThrow("must be a JSON object"); + expect(() => parseSecretReference(FLAG, "[]")).toThrow(`--${FLAG}`); }); test("rejects missing secretId", () => { - expect(() => parseSecretReference(FLAG, '{"jsonKey":"apiKey"}')).toThrow( - 'non-empty "secretId"', - ); + expect(() => parseSecretReference(FLAG, '{"jsonKey":"apiKey"}')).toThrow(`--${FLAG}`); }); test("rejects missing jsonKey", () => { expect(() => parseSecretReference(FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s"}'), - ).toThrow('non-empty "jsonKey"'); + ).toThrow(`--${FLAG}`); }); test("rejects empty secretId", () => { expect(() => parseSecretReference(FLAG, '{"secretId":"","jsonKey":"apiKey"}')).toThrow( - 'non-empty "secretId"', + `--${FLAG}`, ); }); @@ -51,7 +54,7 @@ describe("parseSecretReference", () => { FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":""}', ), - ).toThrow('non-empty "jsonKey"'); + ).toThrow(`--${FLAG}`); }); test("rejects unexpected fields", () => { @@ -60,12 +63,12 @@ describe("parseSecretReference", () => { FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":"apiKey","extra":"bad"}', ), - ).toThrow("unexpected fields: extra"); + ).toThrow(`--${FLAG}`); }); test("rejects non-string secretId", () => { expect(() => parseSecretReference(FLAG, '{"secretId":123,"jsonKey":"apiKey"}')).toThrow( - 'non-empty "secretId"', + `--${FLAG}`, ); }); @@ -75,7 +78,7 @@ describe("parseSecretReference", () => { FLAG, '{"secretId":"arn:aws:secretsmanager:us-west-2:123:secret:s","jsonKey":true}', ), - ).toThrow('non-empty "jsonKey"'); + ).toThrow(`--${FLAG}`); }); test("includes flag name in error messages", () => { diff --git a/src/handlers/identity/parser.tsx b/src/handlers/identity/parser.tsx index 3a10348ed..001cf6670 100644 --- a/src/handlers/identity/parser.tsx +++ b/src/handlers/identity/parser.tsx @@ -1,4 +1,14 @@ -// Requires exactly { secretId, jsonKey } with non-empty string values. +import z from "zod"; +import { InputValidationError } from "../../errors"; + +// Requires exactly { secretId, jsonKey }, both non-empty string, no extra fields. +const secretReferenceSchema = z + .object({ + secretId: z.string().min(1), + jsonKey: z.string().min(1), + }) + .strict(); + export function parseSecretReference( flagName: string, raw: string, @@ -7,28 +17,17 @@ export function parseSecretReference( try { parsed = JSON.parse(raw); } catch (error) { - throw new TypeError( + throw new InputValidationError( `Invalid JSON for --${flagName}: ${error instanceof Error ? error.message : String(error)}`, ); } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new TypeError(`--${flagName} must be a JSON object with "secretId" and "jsonKey"`); - } - - const obj = parsed as Record; - const allowedKeys = new Set(["secretId", "jsonKey"]); - const unexpected = Object.keys(obj).filter((k) => !allowedKeys.has(k)); - if (unexpected.length > 0) { - throw new TypeError(`--${flagName} contains unexpected fields: ${unexpected.join(", ")}`); - } - - if (typeof obj.secretId !== "string" || obj.secretId.length === 0) { - throw new TypeError(`--${flagName} requires a non-empty "secretId" string`); - } - if (typeof obj.jsonKey !== "string" || obj.jsonKey.length === 0) { - throw new TypeError(`--${flagName} requires a non-empty "jsonKey" string`); + const result = secretReferenceSchema.safeParse(parsed); + if (!result.success) { + throw new InputValidationError( + `--${flagName} must be a JSON object with non-empty "secretId" and "jsonKey" fields: ${result.error.message}`, + ); } - return { secretId: obj.secretId, jsonKey: obj.jsonKey }; + return result.data; } From 14b334454aca9a749327151d18543630c1808bdb Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 5 Aug 2026 15:31:04 -0400 Subject: [PATCH 3/3] fix: preserve existing provider config on update, and generalize config validation --- ...tialProviderCommand.304dfc1dc8ca48aa.json} | 4 +- ...tialProviderCommand.4167f0c84f961433.json} | 4 +- ...ntialProviderCommand.1def95784910b125.json | 8 +- ...tialProvidersCommand.23f97c9dcdd6350b.json | 8 +- ...tialProvidersCommand.7d2e22c637f6b633.json | 4 +- ...tialProvidersCommand.d4ab0333e45c9f96.json | 4 +- ...tialProviderCommand.bdf0757fd7db4e91.json} | 8 +- .../__fixtures__/create-2.golden.json | 4 +- .../__fixtures__/create.golden.json | 4 +- .../__fixtures__/get.golden.json | 8 +- .../__fixtures__/list-page-1.golden.json | 4 +- .../__fixtures__/list-page-2.golden.json | 4 +- .../__fixtures__/list.golden.json | 8 +- .../__fixtures__/update.golden.json | 8 +- .../oauth2-credential-provider/config.ts | 159 ++++++ .../create/index.tsx | 102 +--- .../oauth2.fixture.test.tsx | 187 +++++++ .../oauth2.test.tsx | 474 ++++++++++++------ .../update/index.tsx | 144 +++--- src/testing/TestCoreClient.tsx | 80 ++- 20 files changed, 837 insertions(+), 389 deletions(-) rename src/handlers/identity/oauth2-credential-provider/__fixtures__/{CreateOauth2CredentialProviderCommand.15e3501198437ba9.json => CreateOauth2CredentialProviderCommand.304dfc1dc8ca48aa.json} (87%) rename src/handlers/identity/oauth2-credential-provider/__fixtures__/{CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json => CreateOauth2CredentialProviderCommand.4167f0c84f961433.json} (87%) rename src/handlers/identity/oauth2-credential-provider/__fixtures__/{UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json => UpdateOauth2CredentialProviderCommand.bdf0757fd7db4e91.json} (81%) create mode 100644 src/handlers/identity/oauth2-credential-provider/config.ts create mode 100644 src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.304dfc1dc8ca48aa.json similarity index 87% rename from src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json rename to src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.304dfc1dc8ca48aa.json index 6a4e9d0ca..89d2b499f 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.15e3501198437ba9.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.304dfc1dc8ca48aa.json @@ -1,11 +1,11 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-f2d9252c-Wver51" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-7058dd32-VnfWeP" }, "name": "agentcore-cli-oauth2-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/060bb019-777a-45c8-a7dc-d540c1ac8ff9", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/b783b413-142f-4bbd-9f5d-6198ce095ee3", "oauth2ProviderConfigOutput": { "customOauth2ProviderConfig": { "oauthDiscovery": { diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.4167f0c84f961433.json similarity index 87% rename from src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json rename to src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.4167f0c84f961433.json index 25f2c38e6..37b89127c 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.5ccb325cf76fc258.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/CreateOauth2CredentialProviderCommand.4167f0c84f961433.json @@ -1,11 +1,11 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "oauth2ProviderConfigOutput": { "customOauth2ProviderConfig": { "oauthDiscovery": { diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json index cdba1e896..b505582b9 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/GetOauth2CredentialProviderCommand.1def95784910b125.json @@ -1,6 +1,6 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", @@ -14,12 +14,12 @@ } }, "createdTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" }, "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "status": "READY" } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json index 5d644dc71..ccbe89121 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.23f97c9dcdd6350b.json @@ -5,10 +5,10 @@ "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", "createdTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" } }, { @@ -16,10 +16,10 @@ "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", "createdTime": { - "$date": "2026-07-29T14:48:42.317Z" + "$date": "2026-08-05T19:35:45.047Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:42.317Z" + "$date": "2026-08-05T19:35:45.047Z" } } ] diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json index ff12bce4f..6bc2eba33 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.7d2e22c637f6b633.json @@ -5,10 +5,10 @@ "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", "createdTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" } } ], diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json index d4f26bc75..1a485079c 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/ListOauth2CredentialProvidersCommand.d4ab0333e45c9f96.json @@ -5,10 +5,10 @@ "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", "createdTime": { - "$date": "2026-07-29T14:48:42.317Z" + "$date": "2026-08-05T19:35:45.047Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:42.317Z" + "$date": "2026-08-05T19:35:45.047Z" } } ] diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.bdf0757fd7db4e91.json similarity index 81% rename from src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json rename to src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.bdf0757fd7db4e91.json index 39dfe199f..fe3f1c9d9 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.16483b6b51f663af.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/UpdateOauth2CredentialProviderCommand.bdf0757fd7db4e91.json @@ -1,6 +1,6 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderVendor": "CustomOauth2", @@ -14,12 +14,12 @@ } }, "createdTime": { - "$date": "2026-07-29T14:48:42.019Z" + "$date": "2026-08-05T19:35:44.755Z" }, "lastUpdatedTime": { - "$date": "2026-07-29T14:48:43.091Z" + "$date": "2026-08-05T19:35:45.871Z" }, "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "status": "READY" } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json index 6a4e9d0ca..89d2b499f 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create-2.golden.json @@ -1,11 +1,11 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-f2d9252c-Wver51" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-2-7058dd32-VnfWeP" }, "name": "agentcore-cli-oauth2-fixture-2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/060bb019-777a-45c8-a7dc-d540c1ac8ff9", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/b783b413-142f-4bbd-9f5d-6198ce095ee3", "oauth2ProviderConfigOutput": { "customOauth2ProviderConfig": { "oauthDiscovery": { diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json index 25f2c38e6..37b89127c 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/create.golden.json @@ -1,11 +1,11 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "oauth2ProviderConfigOutput": { "customOauth2ProviderConfig": { "oauthDiscovery": { diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json index 1ca35f056..c8df3ddab 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/get.golden.json @@ -1,6 +1,6 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", @@ -13,9 +13,9 @@ "clientId": "fixture-client-id" } }, - "createdTime": "2026-07-29T14:48:42.019Z", - "lastUpdatedTime": "2026-07-29T14:48:42.019Z", + "createdTime": "2026-08-05T19:35:44.755Z", + "lastUpdatedTime": "2026-08-05T19:35:44.755Z", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "status": "READY" } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json index 5ba63b02c..fc3118fcf 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-1.golden.json @@ -4,8 +4,8 @@ "name": "agentcore-cli-oauth2-fixture", "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", - "createdTime": "2026-07-29T14:48:42.019Z", - "lastUpdatedTime": "2026-07-29T14:48:42.019Z" + "createdTime": "2026-08-05T19:35:44.755Z", + "lastUpdatedTime": "2026-08-05T19:35:44.755Z" } ], "nextToken": "eyJBY2NvdW50QW5kVHlwZUdzaVBrIjp7InZhbHVlU3RyIjoiNjg1MTk3NzA4Njg3I09BVVRIMiIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlNLIjp7InZhbHVlU3RyIjoiYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn0sIlBLIjp7InZhbHVlU3RyIjoiQ1JFRFNfUFJPVklERVIjNjg1MTk3NzA4Njg3I2RlZmF1bHQjYWdlbnRjb3JlLWNsaS1vYXV0aDItZml4dHVyZSIsImF0dHJpYnV0ZVR5cGUiOiJTIn19" diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json index a61a3ecc0..97e2f08b4 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list-page-2.golden.json @@ -4,8 +4,8 @@ "name": "agentcore-cli-oauth2-fixture-2", "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", - "createdTime": "2026-07-29T14:48:42.317Z", - "lastUpdatedTime": "2026-07-29T14:48:42.317Z" + "createdTime": "2026-08-05T19:35:45.047Z", + "lastUpdatedTime": "2026-08-05T19:35:45.047Z" } ] } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json index 20f442759..a4b2ec42a 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/list.golden.json @@ -4,15 +4,15 @@ "name": "agentcore-cli-oauth2-fixture", "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture", - "createdTime": "2026-07-29T14:48:42.019Z", - "lastUpdatedTime": "2026-07-29T14:48:42.019Z" + "createdTime": "2026-08-05T19:35:44.755Z", + "lastUpdatedTime": "2026-08-05T19:35:44.755Z" }, { "name": "agentcore-cli-oauth2-fixture-2", "credentialProviderVendor": "CustomOauth2", "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:685197708687:token-vault/default/oauth2credentialprovider/agentcore-cli-oauth2-fixture-2", - "createdTime": "2026-07-29T14:48:42.317Z", - "lastUpdatedTime": "2026-07-29T14:48:42.317Z" + "createdTime": "2026-08-05T19:35:45.047Z", + "lastUpdatedTime": "2026-08-05T19:35:45.047Z" } ] } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json b/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json index 82865c6dc..1888f8498 100644 --- a/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json +++ b/src/handlers/identity/oauth2-credential-provider/__fixtures__/update.golden.json @@ -1,6 +1,6 @@ { "clientSecretArn": { - "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-7aeedc5d-iuTkLZ" + "secretArn": "arn:aws:secretsmanager:us-west-2:685197708687:secret:bedrock-agentcore-identity!default/oauth2/agentcore-cli-oauth2-fixture-d172b1a9-dFOvOv" }, "name": "agentcore-cli-oauth2-fixture", "credentialProviderVendor": "CustomOauth2", @@ -13,9 +13,9 @@ "clientId": "updated-client-id" } }, - "createdTime": "2026-07-29T14:48:42.019Z", - "lastUpdatedTime": "2026-07-29T14:48:43.091Z", + "createdTime": "2026-08-05T19:35:44.755Z", + "lastUpdatedTime": "2026-08-05T19:35:45.871Z", "clientSecretSource": "MANAGED", - "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/6b6e9fda-8f15-4c0f-9d48-b82aa39066d8", + "callbackUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/callback/2edfe7c4-4655-41a5-9722-767aa15e5b45", "status": "READY" } \ No newline at end of file diff --git a/src/handlers/identity/oauth2-credential-provider/config.ts b/src/handlers/identity/oauth2-credential-provider/config.ts new file mode 100644 index 000000000..2f5988453 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/config.ts @@ -0,0 +1,159 @@ +import type { + CredentialProviderVendorType, + CustomOauth2ProviderConfigInput, + CustomOauth2ProviderConfigOutput, + Oauth2AuthorizationServerMetadata, + Oauth2Discovery, + Oauth2ProviderConfigInput, + SecretReference, + SecretSourceType, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../errors"; +import { parseJsonFlag } from "../../utils"; + +export type ProviderConfigMode = + | { + kind: "complete"; + config: Record; + configKey: string; + vendorConfig: Record; + } + | { + kind: "guided"; + clientId?: string; + oauthDiscovery?: Oauth2Discovery; + }; + +interface ProviderConfigFlags { + clientId?: string; + discoveryUrl?: string; + authorizationServerMetadata?: string; + providerConfiguration?: string; +} + +interface SecretUpdate { + clientSecret?: string; + clientSecretConfig?: SecretReference; + clientSecretSource?: SecretSourceType; +} + +interface BuildProviderConfigOptions { + existingCustomConfig?: CustomOauth2ProviderConfigOutput; + secret: SecretUpdate; +} + +export function parseProviderConfigFlags(flags: ProviderConfigFlags): ProviderConfigMode { + const hasProviderConfig = flags.providerConfiguration !== undefined; + const hasDiscoveryUrl = flags.discoveryUrl !== undefined; + const hasAuthServerMetadata = flags.authorizationServerMetadata !== undefined; + const hasGuidedFlags = flags.clientId !== undefined || hasDiscoveryUrl || hasAuthServerMetadata; + + // Complete and guided inputs represent separate configuration modes. + if (hasProviderConfig && hasGuidedFlags) { + throw new InputValidationError( + "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", + ); + } + // OAuth discovery is a union, so only one discovery form can be supplied. + if (hasDiscoveryUrl && hasAuthServerMetadata) { + throw new InputValidationError( + "--discovery-url and --authorization-server-metadata are mutually exclusive", + ); + } + + if (flags.providerConfiguration !== undefined) { + const parsed = parseJsonFlag("provider-configuration", flags.providerConfiguration)!; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new InputValidationError( + "--provider-configuration must contain a single vendor config object", + ); + } + + const config = parsed as Record; + const entries = Object.entries(config); + const [configKey, vendorConfig] = entries[0] ?? []; + if ( + entries.length !== 1 || + !configKey || + typeof vendorConfig !== "object" || + vendorConfig === null || + Array.isArray(vendorConfig) + ) { + throw new InputValidationError( + "--provider-configuration must contain a single vendor config object", + ); + } + + return { + kind: "complete", + config, + configKey, + vendorConfig: vendorConfig as Record, + }; + } + + const authorizationServerMetadata = parseJsonFlag( + "authorization-server-metadata", + flags.authorizationServerMetadata, + ); + const oauthDiscovery: Oauth2Discovery | undefined = + flags.discoveryUrl !== undefined + ? { discoveryUrl: flags.discoveryUrl } + : authorizationServerMetadata + ? { authorizationServerMetadata } + : undefined; + + return { + kind: "guided", + clientId: flags.clientId, + oauthDiscovery, + }; +} + +export function validateProviderConfigMode( + mode: ProviderConfigMode, + vendor: CredentialProviderVendorType, + existingCustomConfig?: CustomOauth2ProviderConfigOutput, +): void { + if (mode.kind === "complete") { + return; + } + + // Guided flags only describe the custom OAuth2 configuration shape. + if (vendor !== "CustomOauth2") { + throw new InputValidationError( + `--provider-configuration is required for --vendor ${vendor}; guided flags only support CustomOauth2`, + ); + } + // Create must supply discovery; update may retain it from the existing config. + if (mode.oauthDiscovery === undefined && existingCustomConfig?.oauthDiscovery === undefined) { + throw new InputValidationError( + "guided --vendor CustomOauth2 requires one of --discovery-url or --authorization-server-metadata", + ); + } +} + +export function buildProviderConfigInput( + mode: ProviderConfigMode, + { existingCustomConfig, secret }: BuildProviderConfigOptions, +): Oauth2ProviderConfigInput { + if (mode.kind === "complete") { + // Complete mode replaces the vendor config after injecting the secret. + return { + ...mode.config, + [mode.configKey]: { + ...mode.vendorConfig, + ...secret, + }, + } as unknown as Oauth2ProviderConfigInput; + } + + // Existing config is the merge base for guided updates and absent on create. + const customConfig: CustomOauth2ProviderConfigInput = { + ...existingCustomConfig, + ...secret, + oauthDiscovery: mode.oauthDiscovery ?? existingCustomConfig?.oauthDiscovery, + ...(mode.clientId !== undefined && { clientId: mode.clientId }), + }; + return { customOauth2ProviderConfig: customConfig }; +} diff --git a/src/handlers/identity/oauth2-credential-provider/create/index.tsx b/src/handlers/identity/oauth2-credential-provider/create/index.tsx index 0dee2bb1f..a57e65ac3 100644 --- a/src/handlers/identity/oauth2-credential-provider/create/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/create/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import type { CredentialProviderVendorType } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; @@ -7,7 +8,11 @@ import type { AppIO } from "../../../../io"; import { coreOptsFromCtx, parseTags } from "../../../utils"; import { SourceResolver } from "../../../../io"; import { parseSecretReference } from "../../parser"; -import { parseJsonFlag } from "../../../utils"; +import { + buildProviderConfigInput, + parseProviderConfigFlags, + validateProviderConfigMode, +} from "../config"; export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ @@ -52,14 +57,9 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI throw new InputValidationError("required option '--vendor ' not specified"); } - const isCustomVendor = flags.vendor === "CustomOauth2"; + const vendor = flags.vendor as CredentialProviderVendorType; const hasClientSecret = flags["client-secret"] !== undefined; const hasSecretRef = flags["client-secret-reference"] !== undefined; - const hasProviderConfig = flags["provider-configuration"] !== undefined; - const hasDiscoveryUrl = flags["discovery-url"] !== undefined; - const hasAuthServerMetadata = flags["authorization-server-metadata"] !== undefined; - const hasGuidedFlags = - flags["client-id"] !== undefined || hasDiscoveryUrl || hasAuthServerMetadata; if (hasClientSecret && hasSecretRef) { throw new InputValidationError( @@ -71,33 +71,14 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI "either --client-secret or --client-secret-reference is required", ); } - if (hasProviderConfig && hasGuidedFlags) { - throw new InputValidationError( - "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", - ); - } - if (hasGuidedFlags && !isCustomVendor) { - throw new InputValidationError( - "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", - ); - } - // non-custom vendors must supply a complete provider-configuration - if (!isCustomVendor && !hasProviderConfig) { - throw new InputValidationError( - `--provider-configuration is required for --vendor ${flags.vendor}; guided flags only support CustomOauth2`, - ); - } - if (hasDiscoveryUrl && hasAuthServerMetadata) { - throw new InputValidationError( - "--discovery-url and --authorization-server-metadata are mutually exclusive", - ); - } - // the guided CustomOAuth2 path requires one discovery form - if (isCustomVendor && !hasProviderConfig && !hasDiscoveryUrl && !hasAuthServerMetadata) { - throw new InputValidationError( - "guided --vendor CustomOauth2 requires one of --discovery-url or --authorization-server-metadata", - ); - } + + const providerConfigMode = parseProviderConfigFlags({ + clientId: flags["client-id"], + discoveryUrl: flags["discovery-url"], + authorizationServerMetadata: flags["authorization-server-metadata"], + providerConfiguration: flags["provider-configuration"], + }); + validateProviderConfigMode(providerConfigMode, vendor); const resolver = new SourceResolver({ stdin: io.stdin }); const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); @@ -108,48 +89,13 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI const clientSecretSource = hasClientSecret ? "MANAGED" : "EXTERNAL"; - let oauth2ProviderConfigInput: Record; - - if (hasProviderConfig) { - const config = parseJsonFlag>( - "provider-configuration", - flags["provider-configuration"], - )!; - const configKey = Object.keys(config)[0]; - if (!configKey || typeof config[configKey] !== "object") { - throw new InputValidationError( - "--provider-configuration must contain a single vendor config object", - ); - } - const vendorConfig = config[configKey] as Record; - vendorConfig.clientSecret = clientSecret; - vendorConfig.clientSecretConfig = clientSecretConfig; - vendorConfig.clientSecretSource = clientSecretSource; - oauth2ProviderConfigInput = config; - } else { - const authServerMetadata = parseJsonFlag>( - "authorization-server-metadata", - flags["authorization-server-metadata"], - ); - - const oauthDiscovery: Record = {}; - if (flags["discovery-url"]) { - oauthDiscovery.discoveryUrl = flags["discovery-url"]; - } - if (authServerMetadata) { - oauthDiscovery.authorizationServerMetadata = authServerMetadata; - } - - oauth2ProviderConfigInput = { - customOauth2ProviderConfig: { - clientId: flags["client-id"], - clientSecret, - clientSecretConfig, - clientSecretSource, - ...(Object.keys(oauthDiscovery).length > 0 && { oauthDiscovery }), - }, - }; - } + const oauth2ProviderConfigInput = buildProviderConfigInput(providerConfigMode, { + secret: { + clientSecret, + clientSecretConfig, + clientSecretSource, + }, + }); const tags = parseTags(flags.tags); @@ -157,8 +103,8 @@ export const createCreateOauth2CredentialProviderHandler = (core: Core, io: AppI await core.identity.createOauth2CredentialProvider( { name: flags.name, - credentialProviderVendor: flags.vendor as any, - oauth2ProviderConfigInput: oauth2ProviderConfigInput as any, + credentialProviderVendor: vendor, + oauth2ProviderConfigInput, tags, }, coreOptsFromCtx(ctx), diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx new file mode 100644 index 000000000..0dec53411 --- /dev/null +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../../core"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import { createRootHandler } from "../../index"; + +const REGION = "us-west-2"; +const FIXTURES = join(import.meta.dir, "__fixtures__"); + +// Record with RECORD=1 bun test src/handlers/identity/oauth2-credential-provider/oauth2.fixture.test.tsx +// Neither fixture provider should exist before recording. The RECORD run creates +// both providers, exercises pagination (requires >=2), then deletes +const FIXTURE_PROVIDER_NAME = "agentcore-cli-oauth2-fixture"; +const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-oauth2-fixture-2"; +const MISSING_PROVIDER_NAME = "missing-oauth2-provider-000"; + +function createFixtureCore(): CoreClient { + const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); + return new CoreClient({ + createControlClient, + createDataClient, + createIamClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[]): Promise { + const io = testIO(); + const root = createRootHandler(createFixtureCore(), { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + await root.route(["node", "agentcore", ...args, "--region", REGION]); + return io.stdout(); +} + +describe("oauth2-credential-provider CRUDL", () => { + test("creates an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CustomOauth2", + "--client-id", + "fixture-client-id", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "fixture-secret", + ]); + + matchGolden(FIXTURES, "create.golden.json", stdout); + }); + + test("creates a second OAuth2 credential provider for pagination", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "create", + "--name", + FIXTURE_PROVIDER_NAME_2, + "--vendor", + "CustomOauth2", + "--client-id", + "fixture-client-id-2", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "fixture-secret-2", + ]); + + matchGolden(FIXTURES, "create-2.golden.json", stdout); + }); + + test("gets an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "get", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "get.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("lists OAuth2 credential providers", async () => { + const stdout = await run(["identity", "oauth2-credential-provider", "list"]); + + matchGolden(FIXTURES, "list.golden.json", stdout); + expect(JSON.parse(stdout).credentialProviders).toBeArray(); + }); + + test("paginates OAuth2 credential provider list with --max-results and --next-token", async () => { + const firstPage = await run([ + "identity", + "oauth2-credential-provider", + "list", + "--max-results", + "1", + ]); + matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); + + const first = JSON.parse(firstPage); + expect(first.credentialProviders).toHaveLength(1); + expect(first.nextToken).toBeString(); + + const secondPage = await run([ + "identity", + "oauth2-credential-provider", + "list", + "--max-results", + "1", + "--next-token", + first.nextToken, + ]); + matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); + expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); + }); + + test("updates an OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "update", + "--name", + FIXTURE_PROVIDER_NAME, + "--vendor", + "CustomOauth2", + "--client-id", + "updated-client-id", + "--discovery-url", + "https://example.com/.well-known/openid-configuration", + "--client-secret", + "updated-secret", + ]); + + matchGolden(FIXTURES, "update.golden.json", stdout); + expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); + }); + + test("deletes the first OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME, + ]); + + matchGolden(FIXTURES, "delete.golden.json", stdout); + }); + + test("deletes the second OAuth2 credential provider", async () => { + const stdout = await run([ + "identity", + "oauth2-credential-provider", + "delete", + "--name", + FIXTURE_PROVIDER_NAME_2, + ]); + + matchGolden(FIXTURES, "delete-2.golden.json", stdout); + }); +}); + +describe("oauth2-credential-provider fixture-backed errors", () => { + test("propagates ResourceNotFoundException from get", async () => { + await expect( + run(["identity", "oauth2-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), + ).rejects.toMatchObject({ + name: "ResourceNotFoundException", + }); + }); +}); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx index 03cb0d1c9..a6b094674 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx @@ -1,50 +1,87 @@ import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; -import { CoreClient } from "../../../core"; +import type { + CustomOauth2ProviderConfigOutput, + GetOauth2CredentialProviderResponse, + UpdateOauth2CredentialProviderRequest, + UpdateOauth2CredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; import { createSilentLogger, - fixtureFactories, - matchGolden, + TestCoreClient, TestGlobalConfigAccessor, testIO, } from "../../../testing"; import { createRootHandler } from "../../index"; +import { + buildProviderConfigInput, + parseProviderConfigFlags, + validateProviderConfigMode, +} from "./config"; const REGION = "us-west-2"; -const FIXTURES = join(import.meta.dir, "__fixtures__"); - -// Record with RECORD=1 bun test src/handlers/identity/oauth2-credential-provider/oauth2.test.tsx -// Neither fixture provider should exist before recording. The RECORD run creates -// both providers, exercises pagination (requires >=2), then deletes -const FIXTURE_PROVIDER_NAME = "agentcore-cli-oauth2-fixture"; -const FIXTURE_PROVIDER_NAME_2 = "agentcore-cli-oauth2-fixture-2"; -const MISSING_PROVIDER_NAME = "missing-oauth2-provider-000"; - -function createFixtureCore(): CoreClient { - const { createControlClient, createDataClient, createIamClient } = fixtureFactories(FIXTURES); - return new CoreClient({ - createControlClient, - createDataClient, - createIamClient, - logger: createSilentLogger(), - }); -} +const PROVIDER_NAME = "oauth2-provider"; + +const EXISTING_CUSTOM_CONFIG: CustomOauth2ProviderConfigOutput = { + oauthDiscovery: { + discoveryUrl: "https://example.com/.well-known/openid-configuration", + }, + clientId: "existing-client-id", + onBehalfOfTokenExchangeConfig: { + grantType: "TOKEN_EXCHANGE", + tokenExchangeGrantTypeConfig: { + actorTokenContent: "NONE", + }, + }, + clientAuthenticationMethod: "CLIENT_SECRET_BASIC", + privateEndpoint: { + selfManagedLatticeResource: { + resourceConfigurationIdentifier: "resource-config", + }, + }, + privateEndpointOverrides: [ + { + domain: "token.example.com", + privateEndpoint: { + selfManagedLatticeResource: { + resourceConfigurationIdentifier: "override-resource-config", + }, + }, + }, + ], +}; -async function run(args: string[]): Promise { +const EXISTING_CUSTOM_RESPONSE = { + name: PROVIDER_NAME, + credentialProviderVendor: "CustomOauth2", + clientSecretSource: "MANAGED", + oauth2ProviderConfigOutput: { + customOauth2ProviderConfig: EXISTING_CUSTOM_CONFIG, + }, +} as GetOauth2CredentialProviderResponse; + +const UPDATE_RESPONSE = { + name: PROVIDER_NAME, + credentialProviderVendor: "CustomOauth2", +} as UpdateOauth2CredentialProviderResponse; + +async function run( + args: string[], + core = new TestCoreClient(), +): Promise<{ core: TestCoreClient; stdout: string }> { const io = testIO(); - const root = createRootHandler(createFixtureCore(), { + const root = createRootHandler(core, { io: io.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), }); await root.route(["node", "agentcore", ...args, "--region", REGION]); - return io.stdout(); + return { core, stdout: io.stdout() }; } describe("oauth2-credential-provider command hierarchy", () => { test("registers the oauth2-credential-provider command hierarchy", () => { - const root = createRootHandler(createFixtureCore(), { + const root = createRootHandler(new TestCoreClient(), { io: testIO().io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -63,145 +100,12 @@ describe("oauth2-credential-provider command hierarchy", () => { ]); }); - test("prints help for bare `identity oauth2-credential-provider` without an SDK call", async () => { - const stdout = await run(["identity", "oauth2-credential-provider"]); + test("prints help for bare `identity oauth2-credential-provider` without a Core call", async () => { + const { core, stdout } = await run(["identity", "oauth2-credential-provider"]); expect(stdout).toContain("Usage: agentcore identity oauth2-credential-provider"); expect(stdout).toContain("Commands:"); - }); -}); - -describe("oauth2-credential-provider CRUDL", () => { - test("creates an OAuth2 credential provider", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "create", - "--name", - FIXTURE_PROVIDER_NAME, - "--vendor", - "CustomOauth2", - "--client-id", - "fixture-client-id", - "--discovery-url", - "https://example.com/.well-known/openid-configuration", - "--client-secret", - "fixture-secret", - ]); - - matchGolden(FIXTURES, "create.golden.json", stdout); - }); - - test("creates a second OAuth2 credential provider for pagination", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "create", - "--name", - FIXTURE_PROVIDER_NAME_2, - "--vendor", - "CustomOauth2", - "--client-id", - "fixture-client-id-2", - "--discovery-url", - "https://example.com/.well-known/openid-configuration", - "--client-secret", - "fixture-secret-2", - ]); - - matchGolden(FIXTURES, "create-2.golden.json", stdout); - }); - - test("gets an OAuth2 credential provider", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "get", - "--name", - FIXTURE_PROVIDER_NAME, - ]); - - matchGolden(FIXTURES, "get.golden.json", stdout); - expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); - }); - - test("lists OAuth2 credential providers", async () => { - const stdout = await run(["identity", "oauth2-credential-provider", "list"]); - - matchGolden(FIXTURES, "list.golden.json", stdout); - expect(JSON.parse(stdout).credentialProviders).toBeArray(); - }); - - test("paginates OAuth2 credential provider list with --max-results and --next-token", async () => { - const firstPage = await run([ - "identity", - "oauth2-credential-provider", - "list", - "--max-results", - "1", - ]); - matchGolden(FIXTURES, "list-page-1.golden.json", firstPage); - - const first = JSON.parse(firstPage); - expect(first.credentialProviders).toHaveLength(1); - expect(first.nextToken).toBeString(); - - const secondPage = await run([ - "identity", - "oauth2-credential-provider", - "list", - "--max-results", - "1", - "--next-token", - first.nextToken, - ]); - matchGolden(FIXTURES, "list-page-2.golden.json", secondPage); - expect(JSON.parse(secondPage).credentialProviders).toHaveLength(1); - }); - - test("updates an OAuth2 credential provider", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "update", - "--name", - FIXTURE_PROVIDER_NAME, - "--vendor", - "CustomOauth2", - "--client-id", - "updated-client-id", - "--discovery-url", - "https://example.com/.well-known/openid-configuration", - "--client-secret", - "updated-secret", - ]); - - matchGolden(FIXTURES, "update.golden.json", stdout); - expect(JSON.parse(stdout).name).toBe(FIXTURE_PROVIDER_NAME); - }); - - test("deletes the first OAuth2 credential provider", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "delete", - "--name", - FIXTURE_PROVIDER_NAME, - ]); - - matchGolden(FIXTURES, "delete.golden.json", stdout); - }); - - test("deletes the second OAuth2 credential provider", async () => { - const stdout = await run([ - "identity", - "oauth2-credential-provider", - "delete", - "--name", - FIXTURE_PROVIDER_NAME_2, - ]); - - matchGolden(FIXTURES, "delete-2.golden.json", stdout); + expect(core.identity.calls).toEqual([]); }); }); @@ -289,6 +193,40 @@ describe("oauth2-credential-provider flag validation", () => { ], /mutually exclusive/, ], + [ + "update: --provider-configuration with guided flags", + [ + "identity", + "oauth2-credential-provider", + "update", + "--name", + "x", + "--client-secret", + "s", + "--client-id", + "c", + "--provider-configuration", + '{"customOauth2ProviderConfig":{"clientId":"c"}}', + ], + /mutually exclusive/, + ], + [ + "update: --discovery-url with --authorization-server-metadata", + [ + "identity", + "oauth2-credential-provider", + "update", + "--name", + "x", + "--client-secret", + "s", + "--discovery-url", + "https://example.com", + "--authorization-server-metadata", + '{"issuer":"https://example.com"}', + ], + /mutually exclusive/, + ], ] as const)("rejects mutually exclusive flags for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); @@ -329,12 +267,216 @@ describe("oauth2-credential-provider flag validation", () => { ] as const)("enforces vendor/config-mode rules for `%s`", async (_label, args, message) => { expect(run([...args])).rejects.toThrow(message); }); +}); + +describe("OAuth2 update configuration", () => { + test("preserves advanced settings during a guided update", () => { + const mode = parseProviderConfigFlags({ clientId: "updated-client-id" }); + + validateProviderConfigMode(mode, "CustomOauth2", EXISTING_CUSTOM_CONFIG); + const result = buildProviderConfigInput(mode, { + existingCustomConfig: EXISTING_CUSTOM_CONFIG, + secret: { + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }, + }); + + expect(result.customOauth2ProviderConfig).toEqual({ + ...EXISTING_CUSTOM_CONFIG, + clientId: "updated-client-id", + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }); + }); + + test("rejects guided updates for non-Custom providers", () => { + const mode = parseProviderConfigFlags({ clientId: "updated-client-id" }); + + expect(() => validateProviderConfigMode(mode, "GithubOauth2")).toThrow( + /--provider-configuration is required for --vendor GithubOauth2/, + ); + }); + + test("requires discovery when an existing Custom config cannot provide it", () => { + const mode = parseProviderConfigFlags({ clientId: "updated-client-id" }); + const existingConfig = { + oauthDiscovery: undefined, + clientId: "existing-client-id", + }; + + expect(() => validateProviderConfigMode(mode, "CustomOauth2", existingConfig)).toThrow( + /requires one of --discovery-url or --authorization-server-metadata/, + ); + }); + + test("overrides discovery without dropping unrelated settings", () => { + const mode = parseProviderConfigFlags({ + discoveryUrl: "https://new.example.com/.well-known/openid-configuration", + }); + const result = buildProviderConfigInput(mode, { + existingCustomConfig: EXISTING_CUSTOM_CONFIG, + secret: { + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }, + }); + + expect(result.customOauth2ProviderConfig).toEqual({ + ...EXISTING_CUSTOM_CONFIG, + oauthDiscovery: { + discoveryUrl: "https://new.example.com/.well-known/openid-configuration", + }, + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }); + }); + + test("injects secrets into a matching complete configuration", () => { + const mode = parseProviderConfigFlags({ + providerConfiguration: '{"githubOauth2ProviderConfig":{"clientId":"new-client-id"}}', + }); + + validateProviderConfigMode(mode, "GithubOauth2"); + const result = buildProviderConfigInput(mode, { + secret: { + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }, + }); + + expect(result).toEqual({ + githubOauth2ProviderConfig: { + clientId: "new-client-id", + clientSecret: "updated-secret", + clientSecretSource: "MANAGED", + }, + }); + }); +}); + +describe("OAuth2 complete provider configuration", () => { + test.each([ + "null", + "[]", + "{}", + '{"githubOauth2ProviderConfig":null}', + '{"customOauth2ProviderConfig":{},"githubOauth2ProviderConfig":{}}', + ])("rejects malformed configuration `%s`", (providerConfiguration) => { + expect(() => parseProviderConfigFlags({ providerConfiguration })).toThrow( + /single vendor config object/, + ); + }); + + test("leaves create vendor and configuration compatibility to the service", () => { + const mode = parseProviderConfigFlags({ + providerConfiguration: '{"githubOauth2ProviderConfig":{"clientId":"client-id"}}', + }); + + expect(() => validateProviderConfigMode(mode, "CustomOauth2")).not.toThrow(); + }); +}); + +describe("OAuth2 update handler", () => { + test("rejects attempts to change the existing vendor before update", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(EXISTING_CUSTOM_RESPONSE); + + await expect( + run( + [ + "identity", + "oauth2-credential-provider", + "update", + "--name", + PROVIDER_NAME, + "--vendor", + "GithubOauth2", + "--client-secret", + "updated-secret", + ], + core, + ), + ).rejects.toThrow(/--vendor cannot be changed.*CustomOauth2.*GithubOauth2/); + + expect(core.identity.calls).toEqual([ + { + method: "getOauth2CredentialProvider", + args: [PROVIDER_NAME, { region: REGION }], + }, + ]); + }); + + test("rejects complete configuration for a different provider type", async () => { + const core = new TestCoreClient(); + core.identity.setGetOauth2Response(EXISTING_CUSTOM_RESPONSE); - test("propagates ResourceNotFoundException from get", async () => { await expect( - run(["identity", "oauth2-credential-provider", "get", "--name", MISSING_PROVIDER_NAME]), - ).rejects.toMatchObject({ - name: "ResourceNotFoundException", + run( + [ + "identity", + "oauth2-credential-provider", + "update", + "--name", + PROVIDER_NAME, + "--client-secret", + "updated-secret", + "--provider-configuration", + '{"githubOauth2ProviderConfig":{"clientId":"updated-client-id"}}', + ], + core, + ), + ).rejects.toThrow(/must use "customOauth2ProviderConfig"/); + + expect(core.identity.calls).toEqual([ + { + method: "getOauth2CredentialProvider", + args: [PROVIDER_NAME, { region: REGION }], + }, + ]); + }); + + test.each([ + ["omitted", []], + ["matching", ["--vendor", "CustomOauth2"]], + ] as const)("preserves the existing config when --vendor is %s", async (_label, vendorArgs) => { + const core = new TestCoreClient(); + core.identity + .setGetOauth2Response(EXISTING_CUSTOM_RESPONSE) + .setUpdateOauth2Response(UPDATE_RESPONSE); + + const { stdout } = await run( + [ + "identity", + "oauth2-credential-provider", + "update", + "--name", + PROVIDER_NAME, + ...vendorArgs, + "--client-secret", + "updated-secret", + ], + core, + ); + + const updateCall = core.identity.calls[1]; + const request = updateCall?.args[0] as UpdateOauth2CredentialProviderRequest; + expect(core.identity.calls.map((call) => call.method)).toEqual([ + "getOauth2CredentialProvider", + "updateOauth2CredentialProvider", + ]); + expect(request).toEqual({ + name: PROVIDER_NAME, + credentialProviderVendor: "CustomOauth2", + oauth2ProviderConfigInput: { + customOauth2ProviderConfig: { + ...EXISTING_CUSTOM_CONFIG, + clientSecret: "updated-secret", + clientSecretConfig: undefined, + clientSecretSource: "MANAGED", + }, + }, }); + expect(JSON.parse(stdout)).toEqual(UPDATE_RESPONSE); }); }); diff --git a/src/handlers/identity/oauth2-credential-provider/update/index.tsx b/src/handlers/identity/oauth2-credential-provider/update/index.tsx index 99c97d606..9df3e1ca4 100644 --- a/src/handlers/identity/oauth2-credential-provider/update/index.tsx +++ b/src/handlers/identity/oauth2-credential-provider/update/index.tsx @@ -1,4 +1,5 @@ import z from "zod"; +import type { Oauth2ProviderConfigOutput } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../../../../errors"; import { createHandler, flag } from "../../../../router"; import { JsonRendererKey } from "../../../../tui"; @@ -7,7 +8,33 @@ import type { AppIO } from "../../../../io"; import { coreOptsFromCtx } from "../../../utils"; import { SourceResolver } from "../../../../io"; import { parseSecretReference } from "../../parser"; -import { parseJsonFlag } from "../../../utils"; +import { + buildProviderConfigInput, + parseProviderConfigFlags, + type ProviderConfigMode, + validateProviderConfigMode, +} from "../config"; + +function validateCompleteConfigKey( + mode: ProviderConfigMode, + existingProviderConfig: Oauth2ProviderConfigOutput | undefined, +): void { + if (mode.kind !== "complete") { + return; + } + + const existingConfigKey = existingProviderConfig + ? Object.keys(existingProviderConfig)[0] + : undefined; + if (!existingConfigKey || existingConfigKey === "$unknown") { + throw new InputValidationError("existing provider is missing a supported configuration"); + } + if (mode.configKey !== existingConfigKey) { + throw new InputValidationError( + `--provider-configuration must use "${existingConfigKey}", received "${mode.configKey}"`, + ); + } +} export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppIO) => createHandler({ @@ -50,11 +77,6 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI const hasClientSecret = flags["client-secret"] !== undefined; const hasSecretRef = flags["client-secret-reference"] !== undefined; - const hasProviderConfig = flags["provider-configuration"] !== undefined; - const hasDiscoveryUrl = flags["discovery-url"] !== undefined; - const hasAuthServerMetadata = flags["authorization-server-metadata"] !== undefined; - const hasGuidedFlags = - flags["client-id"] !== undefined || hasDiscoveryUrl || hasAuthServerMetadata; if (hasClientSecret && hasSecretRef) { throw new InputValidationError( @@ -66,20 +88,29 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI "either --client-secret or --client-secret-reference is required", ); } - if (hasProviderConfig && hasGuidedFlags) { - throw new InputValidationError( - "--provider-configuration and guided flags (--client-id, --discovery-url, --authorization-server-metadata) are mutually exclusive", - ); + + const providerConfigMode = parseProviderConfigFlags({ + clientId: flags["client-id"], + discoveryUrl: flags["discovery-url"], + authorizationServerMetadata: flags["authorization-server-metadata"], + providerConfiguration: flags["provider-configuration"], + }); + + const opts = coreOptsFromCtx(ctx); + const existing = await core.identity.getOauth2CredentialProvider(flags.name, opts); + + // The vendor is required as an update discriminator but cannot be changed. + const vendor = existing.credentialProviderVendor; + if (!vendor) { + throw new InputValidationError("existing provider is missing its vendor"); } - if (hasDiscoveryUrl && hasAuthServerMetadata) { + if (flags.vendor !== undefined && flags.vendor !== vendor) { throw new InputValidationError( - "--discovery-url and --authorization-server-metadata are mutually exclusive", + `--vendor cannot be changed during update: provider uses ${vendor}, received ${flags.vendor}`, ); } - const opts = coreOptsFromCtx(ctx); - const existing = await core.identity.getOauth2CredentialProvider(flags.name, opts); - + // Secret updates must retain the provider's existing ownership model. if (hasClientSecret && existing.clientSecretSource === "EXTERNAL") { throw new InputValidationError( "this provider uses an external secret; use --client-secret-reference to update it", @@ -91,28 +122,13 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI ); } - const vendor = flags.vendor ?? existing.credentialProviderVendor; - if (!vendor) { - throw new InputValidationError("required option '--vendor ' not specified"); - } - const isCustomVendor = vendor === "CustomOauth2"; - if (hasGuidedFlags && !isCustomVendor) { - throw new InputValidationError( - "guided flags (--client-id, --discovery-url, --authorization-server-metadata) are only valid with --vendor CustomOauth2; use --provider-configuration for other vendors", - ); - } - // non-custom vendors must supply a complete provider-configuration - if (!isCustomVendor && !hasProviderConfig) { - throw new InputValidationError( - `--provider-configuration is required for --vendor ${vendor}; guided flags only support CustomOauth2`, - ); - } - // the guided CustomOAuth2 path requires one discovery form - if (isCustomVendor && !hasProviderConfig && !hasDiscoveryUrl && !hasAuthServerMetadata) { - throw new InputValidationError( - "guided --vendor CustomOauth2 requires one of --discovery-url or --authorization-server-metadata", - ); - } + // Guided updates use this output as the base for settings without flags. + const existingCustomConfig = + vendor === "CustomOauth2" + ? existing.oauth2ProviderConfigOutput?.customOauth2ProviderConfig + : undefined; + validateProviderConfigMode(providerConfigMode, vendor, existingCustomConfig); + validateCompleteConfigKey(providerConfigMode, existing.oauth2ProviderConfigOutput); const resolver = new SourceResolver({ stdin: io.stdin }); const clientSecret = await resolver.resolveText("client-secret", flags["client-secret"]); @@ -123,55 +139,21 @@ export const createUpdateOauth2CredentialProviderHandler = (core: Core, io: AppI const clientSecretSource = existing.clientSecretSource; - let oauth2ProviderConfigInput: Record; - - if (hasProviderConfig) { - const config = parseJsonFlag>( - "provider-configuration", - flags["provider-configuration"], - )!; - const configKey = Object.keys(config)[0]; - if (!configKey || typeof config[configKey] !== "object") { - throw new InputValidationError( - "--provider-configuration must contain a single vendor config object", - ); - } - const vendorConfig = config[configKey] as Record; - vendorConfig.clientSecret = clientSecret; - vendorConfig.clientSecretConfig = clientSecretConfig; - vendorConfig.clientSecretSource = clientSecretSource; - oauth2ProviderConfigInput = config; - } else { - const authServerMetadata = parseJsonFlag>( - "authorization-server-metadata", - flags["authorization-server-metadata"], - ); - - const oauthDiscovery: Record = {}; - if (flags["discovery-url"]) { - oauthDiscovery.discoveryUrl = flags["discovery-url"]; - } - if (authServerMetadata) { - oauthDiscovery.authorizationServerMetadata = authServerMetadata; - } - - oauth2ProviderConfigInput = { - customOauth2ProviderConfig: { - clientId: flags["client-id"], - clientSecret, - clientSecretConfig, - clientSecretSource, - ...(Object.keys(oauthDiscovery).length > 0 && { oauthDiscovery }), - }, - }; - } + const oauth2ProviderConfigInput = buildProviderConfigInput(providerConfigMode, { + existingCustomConfig, + secret: { + clientSecret, + clientSecretConfig, + clientSecretSource, + }, + }); ctx.require(JsonRendererKey).renderJson( await core.identity.updateOauth2CredentialProvider( { name: flags.name, - credentialProviderVendor: vendor as any, - oauth2ProviderConfigInput: oauth2ProviderConfigInput as any, + credentialProviderVendor: vendor, + oauth2ProviderConfigInput, }, opts, ), diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 0307eb84d..5328fd5d3 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -921,75 +921,107 @@ type TestCoreClientOptions = { }; class TestIdentityClient implements CoreIdentityClient { + readonly calls: RecordedCall[] = []; + + private getOauth2Response: GetOauth2CredentialProviderResponse = DEFAULT_GET_OAUTH2_RESPONSE; + private updateOauth2Response: UpdateOauth2CredentialProviderResponse = + DEFAULT_UPDATE_OAUTH2_RESPONSE; + + setGetOauth2Response(response: GetOauth2CredentialProviderResponse): this { + this.getOauth2Response = response; + return this; + } + + setUpdateOauth2Response(response: UpdateOauth2CredentialProviderResponse): this { + this.updateOauth2Response = response; + return this; + } + async createApiKeyCredentialProvider( - _input: CreateApiKeyCredentialProviderInput, - _options: CoreOptions, + input: CreateApiKeyCredentialProviderInput, + options: CoreOptions, ): Promise { + this.calls.push({ method: "createApiKeyCredentialProvider", args: [input, options] }); return DEFAULT_CREATE_API_KEY_RESPONSE; } async getApiKeyCredentialProvider( - _name: string, - _options: CoreOptions, + name: string, + options: CoreOptions, ): Promise { + this.calls.push({ method: "getApiKeyCredentialProvider", args: [name, options] }); return DEFAULT_GET_API_KEY_RESPONSE; } async listApiKeyCredentialProviders( - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { + this.calls.push({ + method: "listApiKeyCredentialProviders", + args: [nextToken, maxResults, options], + }); return DEFAULT_LIST_API_KEYS_RESPONSE; } async updateApiKeyCredentialProvider( - _input: UpdateApiKeyCredentialProviderInput, - _options: CoreOptions, + input: UpdateApiKeyCredentialProviderInput, + options: CoreOptions, ): Promise { + this.calls.push({ method: "updateApiKeyCredentialProvider", args: [input, options] }); return DEFAULT_UPDATE_API_KEY_RESPONSE; } async deleteApiKeyCredentialProvider( - _name: string, - _options: CoreOptions, + name: string, + options: CoreOptions, ): Promise { + this.calls.push({ method: "deleteApiKeyCredentialProvider", args: [name, options] }); return DEFAULT_DELETE_API_KEY_RESPONSE; } async createOauth2CredentialProvider( - _input: CreateOauth2CredentialProviderInput, - _options: CoreOptions, + input: CreateOauth2CredentialProviderInput, + options: CoreOptions, ): Promise { + this.calls.push({ method: "createOauth2CredentialProvider", args: [input, options] }); return DEFAULT_CREATE_OAUTH2_RESPONSE; } async getOauth2CredentialProvider( - _name: string, - _options: CoreOptions, + name: string, + options: CoreOptions, ): Promise { - return DEFAULT_GET_OAUTH2_RESPONSE; + this.calls.push({ method: "getOauth2CredentialProvider", args: [name, options] }); + return this.getOauth2Response; } async listOauth2CredentialProviders( - _nextToken: string | undefined, - _maxResults: number | undefined, - _options: CoreOptions, + nextToken: string | undefined, + maxResults: number | undefined, + options: CoreOptions, ): Promise { + this.calls.push({ + method: "listOauth2CredentialProviders", + args: [nextToken, maxResults, options], + }); return DEFAULT_LIST_OAUTH2_RESPONSE; } async updateOauth2CredentialProvider( - _input: UpdateOauth2CredentialProviderInput, - _options: CoreOptions, + input: UpdateOauth2CredentialProviderInput, + options: CoreOptions, ): Promise { - return DEFAULT_UPDATE_OAUTH2_RESPONSE; + this.calls.push({ method: "updateOauth2CredentialProvider", args: [input, options] }); + return this.updateOauth2Response; } async deleteOauth2CredentialProvider( - _name: string, - _options: CoreOptions, + name: string, + options: CoreOptions, ): Promise { + this.calls.push({ method: "deleteOauth2CredentialProvider", args: [name, options] }); return DEFAULT_DELETE_OAUTH2_RESPONSE; } }