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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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