From 9fab58f35bfa02e7f33997b168d665b86344b6d3 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 29 Jul 2026 10:51:07 -0400 Subject: [PATCH 1/2] 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 b80546f97..4608c6c58 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, @@ -36,6 +40,7 @@ import type { MemoryView, UpdateEvaluatorResponse, UpdateApiKeyCredentialProviderResponse, + UpdateOauth2CredentialProviderResponse, UpdateHarnessEndpointRequest, UpdateHarnessEndpointResponse, UpdateHarnessRequest, @@ -55,7 +60,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 { @@ -122,6 +129,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: [] }; @@ -813,6 +827,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 87733368762e3357f2983158c9ce0d9f02bd04e1 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 3 Aug 2026 11:36:24 -0400 Subject: [PATCH 2/2] 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; }