Skip to content

Commit a6bf024

Browse files
authored
fix(fetch): add --identity-name option for custom credential lookup (#715) (#774)
The `fetch access` command hardcoded credential lookup to `<name>-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)
1 parent c9e5cfe commit a6bf024

File tree

8 files changed

+165
-9
lines changed

8 files changed

+165
-9
lines changed

src/cli/commands/fetch/__tests__/fetch-access.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,24 @@ describe('registerFetch', () => {
173173
const renderArg = mockRender.mock.calls[0]![0];
174174
expect(JSON.stringify(renderArg)).toContain('Token fetch failed');
175175
});
176+
177+
it('accepts --identity-name option and passes it through to fetchGatewayToken', async () => {
178+
mockFetchGatewayToken.mockResolvedValue(jwtResult);
179+
180+
await program.parseAsync(
181+
['fetch', 'access', '--name', 'myGateway', '--identity-name', 'my-custom-cred', '--json'],
182+
{
183+
from: 'user',
184+
}
185+
);
186+
187+
expect(mockFetchGatewayToken).toHaveBeenCalledWith(
188+
'myGateway',
189+
expect.objectContaining({ identityName: 'my-custom-cred' })
190+
);
191+
192+
expect(mockLog).toHaveBeenCalledTimes(1);
193+
const output = JSON.parse(mockLog.mock.calls[0][0]);
194+
expect(output.success).toBe(true);
195+
});
176196
});

src/cli/commands/fetch/action.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ async function handleFetchGatewayAccess(options: FetchAccessOptions): Promise<Fe
3232
};
3333
}
3434

35-
const result = await fetchGatewayToken(options.name, { deployTarget: options.target });
35+
const result = await fetchGatewayToken(options.name, {
36+
deployTarget: options.target,
37+
identityName: options.identityName,
38+
});
3639
return { success: true, result };
3740
}
3841

@@ -43,7 +46,10 @@ async function handleFetchAgentAccess(options: FetchAccessOptions): Promise<Fetc
4346

4447
let tokenResult: OAuthTokenResult;
4548
try {
46-
tokenResult = await fetchRuntimeToken(options.name, { deployTarget: options.target });
49+
tokenResult = await fetchRuntimeToken(options.name, {
50+
deployTarget: options.target,
51+
identityName: options.identityName,
52+
});
4753
} catch (err) {
4854
return { success: false, error: err instanceof Error ? err.message : String(err) };
4955
}

src/cli/commands/fetch/command.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const registerFetch = (program: Command) => {
1616
.option('--name <resource>', 'Gateway or agent name [non-interactive]')
1717
.option('--type <type>', 'Resource type: gateway (default) or agent [non-interactive]', 'gateway')
1818
.option('--target <target>', 'Deployment target [non-interactive]')
19+
.option('--identity-name <name>', 'Identity credential name for token fetch [non-interactive]')
1920
.option('--json', 'Output as JSON [non-interactive]')
2021
.action(async (cliOptions: Record<string, unknown>) => {
2122
const options = cliOptions as unknown as FetchAccessOptions;

src/cli/commands/fetch/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export interface FetchAccessOptions {
44
name?: string;
55
type?: FetchResourceType;
66
target?: string;
7+
identityName?: string;
78
json?: boolean;
89
}

src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,5 +367,121 @@ describe('fetchGatewayToken', () => {
367367

368368
await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('Token request failed: 401');
369369
});
370+
371+
it('lists available OAuth credentials in error when no match found', async () => {
372+
const projectSpecWithOtherCred = {
373+
...defaultProjectSpecCustomJwt,
374+
credentials: [
375+
{
376+
authorizerType: 'OAuthCredentialProvider',
377+
name: 'my-custom-identity',
378+
discoveryUrl: DISCOVERY_URL,
379+
},
380+
],
381+
};
382+
383+
const configIO = createMockConfigIO({
384+
projectSpec: projectSpecWithOtherCred,
385+
});
386+
387+
await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow(
388+
'Available OAuth credentials: my-custom-identity'
389+
);
390+
});
391+
392+
it('suggests --identity-name in error when credentials exist but none match', async () => {
393+
const projectSpecWithOtherCred = {
394+
...defaultProjectSpecCustomJwt,
395+
credentials: [
396+
{
397+
authorizerType: 'OAuthCredentialProvider',
398+
name: 'my-custom-identity',
399+
discoveryUrl: DISCOVERY_URL,
400+
},
401+
],
402+
};
403+
404+
const configIO = createMockConfigIO({
405+
projectSpec: projectSpecWithOtherCred,
406+
});
407+
408+
await expect(fetchGatewayToken('myGateway', { configIO })).rejects.toThrow('--identity-name');
409+
});
410+
});
411+
412+
describe('--identity-name option', () => {
413+
it('uses custom identity name instead of default convention', async () => {
414+
vi.mocked(readEnvFile).mockResolvedValue({
415+
AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_SECRET: 'custom-secret',
416+
AGENTCORE_CREDENTIAL_MY_CUSTOM_IDENTITY_CLIENT_ID: 'custom-client',
417+
});
418+
419+
vi.mocked(global.fetch)
420+
.mockResolvedValueOnce({
421+
ok: true,
422+
json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }),
423+
} as Response)
424+
.mockResolvedValueOnce({
425+
ok: true,
426+
json: () => Promise.resolve({ access_token: 'custom-token', expires_in: 1800 }),
427+
} as Response);
428+
429+
const projectSpecWithCustomCred = {
430+
...defaultProjectSpecCustomJwt,
431+
credentials: [
432+
{
433+
authorizerType: 'OAuthCredentialProvider',
434+
name: 'my-custom-identity',
435+
discoveryUrl: DISCOVERY_URL,
436+
},
437+
],
438+
};
439+
440+
const configIO = createMockConfigIO({
441+
projectSpec: projectSpecWithCustomCred,
442+
});
443+
444+
const result = await fetchGatewayToken('myGateway', {
445+
configIO,
446+
identityName: 'my-custom-identity',
447+
});
448+
449+
expect(result).toEqual({
450+
url: GATEWAY_URL,
451+
authType: 'CUSTOM_JWT',
452+
token: 'custom-token',
453+
expiresIn: 1800,
454+
});
455+
});
456+
457+
it('falls back to default convention when identityName not provided', async () => {
458+
vi.mocked(readEnvFile).mockResolvedValue({
459+
AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_SECRET: 'test-secret',
460+
AGENTCORE_CREDENTIAL_MYGATEWAY_OAUTH_CLIENT_ID: 'test-client',
461+
});
462+
463+
vi.mocked(global.fetch)
464+
.mockResolvedValueOnce({
465+
ok: true,
466+
json: () => Promise.resolve({ token_endpoint: TOKEN_ENDPOINT }),
467+
} as Response)
468+
.mockResolvedValueOnce({
469+
ok: true,
470+
json: () => Promise.resolve({ access_token: 'test-token', expires_in: 3600 }),
471+
} as Response);
472+
473+
const configIO = createMockConfigIO({
474+
projectSpec: defaultProjectSpecCustomJwt,
475+
});
476+
477+
const result = await fetchGatewayToken('myGateway', { configIO });
478+
479+
expect(result).toEqual({
480+
url: GATEWAY_URL,
481+
authType: 'CUSTOM_JWT',
482+
token: 'test-token',
483+
expiresIn: 3600,
484+
});
485+
});
370486
});
371487
});

src/cli/operations/fetch-access/fetch-gateway-token.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TokenFetchResult } from './types';
44

55
export async function fetchGatewayToken(
66
gatewayName: string,
7-
options: { configIO?: ConfigIO; deployTarget?: string } = {}
7+
options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {}
88
): Promise<TokenFetchResult> {
99
const configIO = options.configIO ?? new ConfigIO();
1010

@@ -71,6 +71,7 @@ export async function fetchGatewayToken(
7171
deployedState,
7272
targetName,
7373
credentials: projectSpec.credentials,
74+
credentialName: options.identityName,
7475
});
7576

7677
return {

src/cli/operations/fetch-access/fetch-runtime-token.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import type { OAuthTokenResult } from './oauth-token';
1212
* Returns true only if the managed OAuth credential exists in the project
1313
* spec AND the client secret is available in .env.local.
1414
*/
15-
export async function canFetchRuntimeToken(agentName: string, options: { configIO?: ConfigIO } = {}): Promise<boolean> {
15+
export async function canFetchRuntimeToken(
16+
agentName: string,
17+
options: { configIO?: ConfigIO; identityName?: string } = {}
18+
): Promise<boolean> {
1619
try {
1720
const configIO = options.configIO ?? new ConfigIO();
1821
const projectSpec = await configIO.readProjectSpec();
@@ -21,7 +24,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI
2124
if (!agentSpec?.authorizerType || agentSpec.authorizerType !== 'CUSTOM_JWT') return false;
2225
if (!agentSpec.authorizerConfiguration?.customJwtAuthorizer) return false;
2326

24-
const credName = computeManagedOAuthCredentialName(agentName);
27+
const credName = options.identityName ?? computeManagedOAuthCredentialName(agentName);
2528
const hasCredential = projectSpec.credentials.some(
2629
c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName
2730
);
@@ -43,7 +46,7 @@ export async function canFetchRuntimeToken(agentName: string, options: { configI
4346
*/
4447
export async function fetchRuntimeToken(
4548
agentName: string,
46-
options: { configIO?: ConfigIO; deployTarget?: string } = {}
49+
options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {}
4750
): Promise<OAuthTokenResult> {
4851
const configIO = options.configIO ?? new ConfigIO();
4952

@@ -80,5 +83,6 @@ export async function fetchRuntimeToken(
8083
deployedState,
8184
targetName,
8285
credentials: projectSpec.credentials,
86+
credentialName: options.identityName,
8387
});
8488
}

src/cli/operations/fetch-access/oauth-token.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,24 @@ export async function fetchOAuthToken(opts: {
3131
targetName: string;
3232
/** Project credentials list */
3333
credentials: { authorizerType: string; name: string }[];
34+
/** Optional explicit credential name. When omitted, defaults to `<resourceName>-oauth`. */
35+
credentialName?: string;
3436
}): Promise<OAuthTokenResult> {
3537
const { resourceName, jwtConfig, deployedState, targetName, credentials } = opts;
3638

37-
const credName = computeManagedOAuthCredentialName(resourceName);
39+
const credName = opts.credentialName ?? computeManagedOAuthCredentialName(resourceName);
3840

3941
// Validate credential exists in project spec
4042
const credential = credentials.find(c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName);
4143
if (!credential) {
44+
const availableOAuth = credentials.filter(c => c.authorizerType === 'OAuthCredentialProvider').map(c => c.name);
45+
const availableHint =
46+
availableOAuth.length > 0
47+
? ` Available OAuth credentials: ${availableOAuth.join(', ')}. Use --identity-name to specify one.`
48+
: '';
4249
throw new Error(
43-
`No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'. ` +
44-
`Re-create the resource with --client-id and --client-secret.`
50+
`No managed OAuth credential found for '${resourceName}'. Expected credential '${credName}'.${availableHint}` +
51+
(availableOAuth.length === 0 ? ` Re-create the resource with --client-id and --client-secret.` : '')
4552
);
4653
}
4754

0 commit comments

Comments
 (0)