Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/cli/commands/fetch/__tests__/fetch-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
10 changes: 8 additions & 2 deletions src/cli/commands/fetch/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ async function handleFetchGatewayAccess(options: FetchAccessOptions): Promise<Fe
};
}

const result = await fetchGatewayToken(options.name, { deployTarget: options.target });
const result = await fetchGatewayToken(options.name, {
deployTarget: options.target,
identityName: options.identityName,
});
return { success: true, result };
}

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

let tokenResult: OAuthTokenResult;
try {
tokenResult = await fetchRuntimeToken(options.name, { deployTarget: options.target });
tokenResult = await fetchRuntimeToken(options.name, {
deployTarget: options.target,
identityName: options.identityName,
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/fetch/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
.option('--name <resource>', 'Gateway or agent name [non-interactive]')
.option('--type <type>', 'Resource type: gateway (default) or agent [non-interactive]', 'gateway')
.option('--target <target>', 'Deployment target [non-interactive]')
.option('--identity-name <name>', 'Identity credential name for token fetch [non-interactive]')
.option('--json', 'Output as JSON [non-interactive]')
.action(async (cliOptions: Record<string, unknown>) => {
const options = cliOptions as unknown as FetchAccessOptions;
Expand All @@ -26,7 +27,7 @@
result = await handleFetchAccess(options);
} catch (error) {
if (options.json) {
console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to availableOAuth
as clear text.
} else {
render(<Text color="red">Error: {getErrorMessage(error)}</Text>);
}
Expand All @@ -37,11 +38,11 @@
if (!result.success) {
if (options.json) {
console.log(
JSON.stringify({
success: false,
error: result.error,
...(result.availableGateways && { availableGateways: result.availableGateways }),
})

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to availableOAuth
as clear text.
);
} else if (!result.availableGateways) {
render(<Text color="red">{result.error}</Text>);
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/fetch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export interface FetchAccessOptions {
name?: string;
type?: FetchResourceType;
target?: string;
identityName?: string;
json?: boolean;
}
116 changes: 116 additions & 0 deletions src/cli/operations/fetch-access/__tests__/fetch-gateway-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
});
3 changes: 2 additions & 1 deletion src/cli/operations/fetch-access/fetch-gateway-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenFetchResult> {
const configIO = options.configIO ?? new ConfigIO();

Expand Down Expand Up @@ -71,6 +71,7 @@ export async function fetchGatewayToken(
deployedState,
targetName,
credentials: projectSpec.credentials,
credentialName: options.identityName,
});

return {
Expand Down
10 changes: 7 additions & 3 deletions src/cli/operations/fetch-access/fetch-runtime-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
export async function canFetchRuntimeToken(
agentName: string,
options: { configIO?: ConfigIO; identityName?: string } = {}
): Promise<boolean> {
try {
const configIO = options.configIO ?? new ConfigIO();
const projectSpec = await configIO.readProjectSpec();
Expand All @@ -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
);
Expand All @@ -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<OAuthTokenResult> {
const configIO = options.configIO ?? new ConfigIO();

Expand Down Expand Up @@ -80,5 +83,6 @@ export async function fetchRuntimeToken(
deployedState,
targetName,
credentials: projectSpec.credentials,
credentialName: options.identityName,
});
}
13 changes: 10 additions & 3 deletions src/cli/operations/fetch-access/oauth-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<resourceName>-oauth`. */
credentialName?: string;
}): Promise<OAuthTokenResult> {
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.` : '')
);
}

Expand Down
Loading