From 69735d13f017c5e78233808e150f346c1178557b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 6 Apr 2026 12:13:13 -0400 Subject: [PATCH] fix(fetch): add --identity-name option for custom credential lookup (#715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `fetch access` command hardcoded credential lookup to `-oauth` via `computeManagedOAuthCredentialName()`, causing failures when users create identities with custom names. This adds an `--identity-name` option that lets users specify which credential to use for OAuth token fetch, falling back to the default convention when omitted. When no matching credential is found, the error message now lists all available OAuth credentials and suggests using `--identity-name`. Constraint: Must remain backward compatible — omitting --identity-name preserves existing behavior Rejected: Modify computeManagedOAuthCredentialName globally | would break other consumers Confidence: high Scope-risk: narrow Not-tested: TUI interactive flow and invoke command auto-fetch paths (noted as follow-up) --- .../fetch/__tests__/fetch-access.test.ts | 20 +++ src/cli/commands/fetch/action.ts | 10 +- src/cli/commands/fetch/command.tsx | 1 + src/cli/commands/fetch/types.ts | 1 + .../__tests__/fetch-gateway-token.test.ts | 116 ++++++++++++++++++ .../fetch-access/fetch-gateway-token.ts | 3 +- .../fetch-access/fetch-runtime-token.ts | 10 +- .../operations/fetch-access/oauth-token.ts | 13 +- 8 files changed, 165 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/fetch/__tests__/fetch-access.test.ts b/src/cli/commands/fetch/__tests__/fetch-access.test.ts index 92f2d0081..76f5e5557 100644 --- a/src/cli/commands/fetch/__tests__/fetch-access.test.ts +++ b/src/cli/commands/fetch/__tests__/fetch-access.test.ts @@ -173,4 +173,24 @@ describe('registerFetch', () => { const renderArg = mockRender.mock.calls[0]![0]; expect(JSON.stringify(renderArg)).toContain('Token fetch failed'); }); + + it('accepts --identity-name option and passes it through to fetchGatewayToken', async () => { + mockFetchGatewayToken.mockResolvedValue(jwtResult); + + await program.parseAsync( + ['fetch', 'access', '--name', 'myGateway', '--identity-name', 'my-custom-cred', '--json'], + { + from: 'user', + } + ); + + expect(mockFetchGatewayToken).toHaveBeenCalledWith( + 'myGateway', + expect.objectContaining({ identityName: 'my-custom-cred' }) + ); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0][0]); + expect(output.success).toBe(true); + }); }); diff --git a/src/cli/commands/fetch/action.ts b/src/cli/commands/fetch/action.ts index 605428b0f..c8bd44091 100644 --- a/src/cli/commands/fetch/action.ts +++ b/src/cli/commands/fetch/action.ts @@ -32,7 +32,10 @@ async function handleFetchGatewayAccess(options: FetchAccessOptions): Promise { .option('--name ', 'Gateway or agent name [non-interactive]') .option('--type ', 'Resource type: gateway (default) or agent [non-interactive]', 'gateway') .option('--target ', 'Deployment target [non-interactive]') + .option('--identity-name ', 'Identity credential name for token fetch [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async (cliOptions: Record) => { const options = cliOptions as unknown as FetchAccessOptions; diff --git a/src/cli/commands/fetch/types.ts b/src/cli/commands/fetch/types.ts index 2d4ffa36f..43b12d9cc 100644 --- a/src/cli/commands/fetch/types.ts +++ b/src/cli/commands/fetch/types.ts @@ -4,5 +4,6 @@ export interface FetchAccessOptions { name?: string; type?: FetchResourceType; target?: string; + identityName?: string; json?: boolean; } diff --git a/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts b/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts index 12da67441..ac556ab24 100644 --- a/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts +++ b/src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts @@ -367,5 +367,121 @@ describe('fetchGatewayToken', () => { await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('Token request failed: 401'); }); + + it('lists available OAuth credentials in error when no match found', async () => { + const projectSpecWithOtherCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithOtherCred, + }); + + await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow( + 'Available OAuth credentials: my-custom-identity' + ); + }); + + it('suggests --identity-name in error when credentials exist but none match', async () => { + const projectSpecWithOtherCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithOtherCred, + }); + + await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('--identity-name'); + }); + }); + + describe('--identity-name option', () => { + it('uses custom identity name instead of default convention', async () => { + vi.mocked(readEnvFile).mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_SECRET: 'custom-secret', + AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_ID: 'custom-client', + }); + + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'custom-token', expires_in: 1800 }), + } as Response); + + const projectSpecWithCustomCred = { + ...defaultProjectSpecCustomJwt, + credentials: [ + { + authorizerType: 'OAuthCredentialProvider', + name: 'my-custom-identity', + discoveryUrl: DISCOVERY_URL, + }, + ], + }; + + const configIO = createMockConfigIO({ + projectSpec: projectSpecWithCustomCred, + }); + + const result = await fetchGatewayToken('myGateway', { + configIO, + identityName: 'my-custom-identity', + }); + + expect(result).toEqual({ + url: GATEWAY_URL, + authType: 'CUSTOM_JWT', + token: 'custom-token', + expiresIn: 1800, + }); + }); + + it('falls back to default convention when identityName not provided', async () => { + vi.mocked(readEnvFile).mockResolvedValue({ + AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_SECRET: 'test-secret', + AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_ID: 'test-client', + }); + + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'test-token', expires_in: 3600 }), + } as Response); + + const configIO = createMockConfigIO({ + projectSpec: defaultProjectSpecCustomJwt, + }); + + const result = await fetchGatewayToken('myGateway', { configIO }); + + expect(result).toEqual({ + url: GATEWAY_URL, + authType: 'CUSTOM_JWT', + token: 'test-token', + expiresIn: 3600, + }); + }); }); }); diff --git a/src/cli/operations/fetch-access/fetch-gateway-token.ts b/src/cli/operations/fetch-access/fetch-gateway-token.ts index 68e7a46f5..d02b4ba78 100644 --- a/src/cli/operations/fetch-access/fetch-gateway-token.ts +++ b/src/cli/operations/fetch-access/fetch-gateway-token.ts @@ -4,7 +4,7 @@ import type { TokenFetchResult } from './types'; export async function fetchGatewayToken( gatewayName: string, - options: { configIO?: ConfigIO; deployTarget?: string } = {} + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} ): Promise { const configIO = options.configIO ?? new ConfigIO(); @@ -71,6 +71,7 @@ export async function fetchGatewayToken( deployedState, targetName, credentials: projectSpec.credentials, + credentialName: options.identityName, }); return { diff --git a/src/cli/operations/fetch-access/fetch-runtime-token.ts b/src/cli/operations/fetch-access/fetch-runtime-token.ts index 0b9235356..87b0017ec 100644 --- a/src/cli/operations/fetch-access/fetch-runtime-token.ts +++ b/src/cli/operations/fetch-access/fetch-runtime-token.ts @@ -12,7 +12,10 @@ import type { OAuthTokenResult } from './oauth-token'; * Returns true only if the managed OAuth credential exists in the project * spec AND the client secret is available in .env.local. */ -export async function canFetchRuntimeToken(agentName: string, options: { configIO?: ConfigIO } = {}): Promise { +export async function canFetchRuntimeToken( + agentName: string, + options: { configIO?: ConfigIO; identityName?: string } = {} +): Promise { try { const configIO = options.configIO ?? new ConfigIO(); const projectSpec = await configIO.readProjectSpec(); @@ -21,7 +24,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI if (!agentSpec?.authorizerType || agentSpec.authorizerType !== 'CUSTOM_JWT') return false; if (!agentSpec.authorizerConfiguration?.customJwtAuthorizer) return false; - const credName = computeManagedOAuthCredentialName(agentName); + const credName = options.identityName ?? computeManagedOAuthCredentialName(agentName); const hasCredential = projectSpec.credentials.some( c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName ); @@ -43,7 +46,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI */ export async function fetchRuntimeToken( agentName: string, - options: { configIO?: ConfigIO; deployTarget?: string } = {} + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} ): Promise { const configIO = options.configIO ?? new ConfigIO(); @@ -80,5 +83,6 @@ export async function fetchRuntimeToken( deployedState, targetName, credentials: projectSpec.credentials, + credentialName: options.identityName, }); } diff --git a/src/cli/operations/fetch-access/oauth-token.ts b/src/cli/operations/fetch-access/oauth-token.ts index 6b18f1461..22620eb97 100644 --- a/src/cli/operations/fetch-access/oauth-token.ts +++ b/src/cli/operations/fetch-access/oauth-token.ts @@ -31,17 +31,24 @@ export async function fetchOAuthToken(opts: { targetName: string; /** Project credentials list */ credentials: { authorizerType: string; name: string }[]; + /** Optional explicit credential name. When omitted, defaults to `-oauth`. */ + credentialName?: string; }): Promise { const { resourceName, jwtConfig, deployedState, targetName, credentials } = opts; - const credName = computeManagedOAuthCredentialName(resourceName); + const credName = opts.credentialName ?? computeManagedOAuthCredentialName(resourceName); // Validate credential exists in project spec const credential = credentials.find(c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName); if (!credential) { + const availableOAuth = credentials.filter(c => c.authorizerType === 'OAuthCredentialProvider').map(c => c.name); + const availableHint = + availableOAuth.length > 0 + ? ` Available OAuth credentials: ${availableOAuth.join(', ')}. Use --identity-name to specify one.` + : ''; throw new Error( - `No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'. ` + - `Re-create the resource with --client-id and --client-secret.` + `No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'.${availableHint}` + + (availableOAuth.length === 0 ? ` Re-create the resource with --client-id and --client-secret.` : '') ); }