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.` : '') ); }