Skip to content

Commit 1a7a386

Browse files
committed
feat(gateway): add Custom JWT inbound auth for MCP gateways
Add CUSTOM_JWT authorizer support to the gateway creation flow: - TUI wizard for JWT configuration (discovery URL, audiences, clients, scopes, custom claim validations with string/string-array match types) - CLI flags for non-interactive gateway creation with JWT auth - Schema validation with OIDC discovery URL HTTPS enforcement, XOR validation for claim match values, and .strict() on config - Fix gateway output key comment to match Gateway{Name} pattern (CDK fix in companion agentcore-cdk PR)
1 parent 686dbee commit 1a7a386

22 files changed

Lines changed: 2497 additions & 259 deletions

integ-tests/tui/add-gateway-jwt.test.ts

Lines changed: 415 additions & 0 deletions
Large diffs are not rendered by default.

integ-tests/tui/setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* TUI integration test setup.
3+
*
4+
* This file is referenced by vitest.config.ts as a setupFile for the 'tui' project.
5+
* It runs before each test file in integ-tests/tui/.
6+
*/

src/cli/cloudformation/outputs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function parseGatewayOutputs(
5252
const gatewayNames = Object.keys(gatewaySpecs);
5353
const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name]));
5454

55-
// Match patterns: Gateway{Name}{Type}Output
55+
// Match patterns: Gateway{Name}{Type}Output{Hash}
5656
const outputPattern = /^Gateway(.+?)(Id|Arn|Url)Output/;
5757

5858
for (const [key, value] of Object.entries(outputs)) {

src/cli/commands/add/__tests__/add-gateway.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ describe('add gateway command', () => {
167167
'client1',
168168
'--allowed-scopes',
169169
'scope1,scope2',
170-
'--agent-client-id',
170+
'--client-id',
171171
'agent-cid',
172-
'--agent-client-secret',
172+
'--client-secret',
173173
'agent-secret',
174174
'--json',
175175
],

src/cli/commands/add/__tests__/validate.test.ts

Lines changed: 111 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -220,19 +220,24 @@ describe('validate', () => {
220220
expect(result.error?.includes('Invalid authorizer type')).toBeTruthy();
221221
});
222222

223-
// AC11: CUSTOM_JWT requires discoveryUrl and allowedClients (allowedAudience is optional)
223+
// AC11: CUSTOM_JWT requires discoveryUrl; at least one of allowedAudience/allowedClients/allowedScopes
224224
it('returns error for CUSTOM_JWT missing required fields', () => {
225-
const jwtFields: { field: keyof AddGatewayOptions; error: string }[] = [
226-
{ field: 'discoveryUrl', error: '--discovery-url is required for CUSTOM_JWT authorizer' },
227-
{ field: 'allowedClients', error: '--allowed-clients is required for CUSTOM_JWT authorizer' },
228-
];
225+
// discoveryUrl is always required
226+
const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, discoveryUrl: undefined });
227+
expect(result.valid).toBe(false);
228+
expect(result.error).toBe('--discovery-url is required for CUSTOM_JWT authorizer');
229229

230-
for (const { field, error } of jwtFields) {
231-
const opts = { ...validGatewayOptionsJwt, [field]: undefined };
232-
const result = validateAddGatewayOptions(opts);
233-
expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false);
234-
expect(result.error).toBe(error);
235-
}
230+
// All three optional fields absent fails
231+
const noneResult = validateAddGatewayOptions({
232+
...validGatewayOptionsJwt,
233+
allowedAudience: undefined,
234+
allowedClients: undefined,
235+
allowedScopes: undefined,
236+
});
237+
expect(noneResult.valid).toBe(false);
238+
expect(noneResult.error).toBe(
239+
'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer'
240+
);
236241
});
237242

238243
// AC11b: allowedAudience is optional
@@ -255,11 +260,86 @@ describe('validate', () => {
255260
expect(result.error?.includes('.well-known/openid-configuration')).toBeTruthy();
256261
});
257262

258-
// AC13: Empty comma-separated clients rejected (audience can be empty)
259-
it('returns error for empty clients', () => {
260-
const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, allowedClients: ' , ' });
263+
// AC13: At least one of audience/clients/scopes must be non-empty
264+
it('returns error when all of audience, clients, and scopes are empty', () => {
265+
const result = validateAddGatewayOptions({
266+
...validGatewayOptionsJwt,
267+
allowedAudience: ' ',
268+
allowedClients: undefined,
269+
allowedScopes: undefined,
270+
});
271+
expect(result.valid).toBe(false);
272+
expect(result.error).toBe(
273+
'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer'
274+
);
275+
});
276+
277+
// AC-claims1: --custom-claims with valid JSON passes validation
278+
it('accepts valid --custom-claims JSON', () => {
279+
const result = validateAddGatewayOptions({
280+
...validGatewayOptionsJwt,
281+
customClaims: JSON.stringify([
282+
{
283+
inboundTokenClaimName: 'dept',
284+
inboundTokenClaimValueType: 'STRING',
285+
authorizingClaimMatchValue: {
286+
claimMatchOperator: 'EQUALS',
287+
claimMatchValue: { matchValueString: 'engineering' },
288+
},
289+
},
290+
]),
291+
});
292+
expect(result.valid).toBe(true);
293+
});
294+
295+
// AC-claims2: --custom-claims alone satisfies the "at least one constraint" check
296+
it('allows CUSTOM_JWT with only --custom-claims (no audience/clients/scopes)', () => {
297+
const result = validateAddGatewayOptions({
298+
name: 'test-gw',
299+
authorizerType: 'CUSTOM_JWT',
300+
discoveryUrl: 'https://example.com/.well-known/openid-configuration',
301+
customClaims: JSON.stringify([
302+
{
303+
inboundTokenClaimName: 'role',
304+
inboundTokenClaimValueType: 'STRING_ARRAY',
305+
authorizingClaimMatchValue: {
306+
claimMatchOperator: 'CONTAINS_ANY',
307+
claimMatchValue: { matchValueStringList: ['admin'] },
308+
},
309+
},
310+
]),
311+
});
312+
expect(result.valid).toBe(true);
313+
});
314+
315+
// AC-claims3: --custom-claims with invalid JSON fails
316+
it('returns error for --custom-claims with invalid JSON', () => {
317+
const result = validateAddGatewayOptions({
318+
...validGatewayOptionsJwt,
319+
customClaims: 'not json',
320+
});
321+
expect(result.valid).toBe(false);
322+
expect(result.error).toBe('--custom-claims must be valid JSON');
323+
});
324+
325+
// AC-claims4: --custom-claims with empty array fails
326+
it('returns error for --custom-claims with empty array', () => {
327+
const result = validateAddGatewayOptions({
328+
...validGatewayOptionsJwt,
329+
customClaims: '[]',
330+
});
331+
expect(result.valid).toBe(false);
332+
expect(result.error).toBe('--custom-claims must be a non-empty JSON array');
333+
});
334+
335+
// AC-claims5: --custom-claims with invalid claim structure fails
336+
it('returns error for --custom-claims with invalid claim structure', () => {
337+
const result = validateAddGatewayOptions({
338+
...validGatewayOptionsJwt,
339+
customClaims: JSON.stringify([{ badField: 'value' }]),
340+
});
261341
expect(result.valid).toBe(false);
262-
expect(result.error).toBe('At least one client value is required');
342+
expect(result.error).toContain('Invalid custom claim at index 0');
263343
});
264344

265345
// AC14: Valid options pass
@@ -268,42 +348,42 @@ describe('validate', () => {
268348
expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true });
269349
});
270350

271-
// AC15: agentClientId and agentClientSecret must be provided together
272-
it('returns error when agentClientId provided without agentClientSecret', () => {
351+
// AC15: clientId and clientSecret must be provided together
352+
it('returns error when clientId provided without clientSecret', () => {
273353
const result = validateAddGatewayOptions({
274354
...validGatewayOptionsJwt,
275-
agentClientId: 'my-client-id',
355+
clientId: 'my-client-id',
276356
});
277357
expect(result.valid).toBe(false);
278-
expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together');
358+
expect(result.error).toBe('Both --client-id and --client-secret must be provided together');
279359
});
280360

281-
it('returns error when agentClientSecret provided without agentClientId', () => {
361+
it('returns error when clientSecret provided without clientId', () => {
282362
const result = validateAddGatewayOptions({
283363
...validGatewayOptionsJwt,
284-
agentClientSecret: 'my-secret',
364+
clientSecret: 'my-secret',
285365
});
286366
expect(result.valid).toBe(false);
287-
expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together');
367+
expect(result.error).toBe('Both --client-id and --client-secret must be provided together');
288368
});
289369

290-
// AC16: agent credentials only valid with CUSTOM_JWT
291-
it('returns error when agent credentials used with non-CUSTOM_JWT authorizer', () => {
370+
// AC16: OAuth client credentials only valid with CUSTOM_JWT
371+
it('returns error when OAuth client credentials used with non-CUSTOM_JWT authorizer', () => {
292372
const result = validateAddGatewayOptions({
293373
...validGatewayOptionsNone,
294-
agentClientId: 'my-client-id',
295-
agentClientSecret: 'my-secret',
374+
clientId: 'my-client-id',
375+
clientSecret: 'my-secret',
296376
});
297377
expect(result.valid).toBe(false);
298-
expect(result.error).toBe('Agent OAuth credentials are only valid with CUSTOM_JWT authorizer');
378+
expect(result.error).toBe('OAuth client credentials are only valid with CUSTOM_JWT authorizer');
299379
});
300380

301-
// AC17: valid CUSTOM_JWT with agent credentials passes
302-
it('passes for CUSTOM_JWT with agent credentials', () => {
381+
// AC17: valid CUSTOM_JWT with OAuth client credentials passes
382+
it('passes for CUSTOM_JWT with OAuth client credentials', () => {
303383
const result = validateAddGatewayOptions({
304384
...validGatewayOptionsJwt,
305-
agentClientId: 'my-client-id',
306-
agentClientSecret: 'my-secret',
385+
clientId: 'my-client-id',
386+
clientSecret: 'my-secret',
307387
allowedScopes: 'scope1,scope2',
308388
});
309389
expect(result.valid).toBe(true);

src/cli/commands/add/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ export interface AddGatewayOptions {
3434
allowedAudience?: string;
3535
allowedClients?: string;
3636
allowedScopes?: string;
37-
agentClientId?: string;
38-
agentClientSecret?: string;
37+
customClaims?: string;
38+
clientId?: string;
39+
clientSecret?: string;
3940
agents?: string;
4041
semanticSearch?: boolean;
4142
exceptionLevel?: string;

src/cli/commands/add/validate.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ConfigIO, findConfigRoot } from '../../../lib';
22
import {
33
AgentNameSchema,
44
BuildTypeSchema,
5+
CustomClaimValidationSchema,
56
GatewayExceptionLevelSchema,
67
GatewayNameSchema,
78
ModelProviderSchema,
@@ -230,7 +231,10 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio
230231
}
231232

232233
try {
233-
new URL(options.discoveryUrl);
234+
const url = new URL(options.discoveryUrl);
235+
if (url.protocol !== 'https:') {
236+
return { valid: false, error: 'Discovery URL must use HTTPS' };
237+
}
234238
} catch {
235239
return { valid: false, error: 'Discovery URL must be a valid URL' };
236240
}
@@ -239,30 +243,49 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio
239243
return { valid: false, error: `Discovery URL must end with ${OIDC_WELL_KNOWN_SUFFIX}` };
240244
}
241245

242-
// allowedAudience is optional - empty means no audience validation
243-
244-
if (!options.allowedClients) {
245-
return { valid: false, error: '--allowed-clients is required for CUSTOM_JWT authorizer' };
246+
// Validate custom claims JSON if provided
247+
if (options.customClaims) {
248+
let parsed: unknown;
249+
try {
250+
parsed = JSON.parse(options.customClaims);
251+
} catch {
252+
return { valid: false, error: '--custom-claims must be valid JSON' };
253+
}
254+
if (!Array.isArray(parsed) || parsed.length === 0) {
255+
return { valid: false, error: '--custom-claims must be a non-empty JSON array' };
256+
}
257+
for (const [i, entry] of parsed.entries()) {
258+
const result = CustomClaimValidationSchema.safeParse(entry);
259+
if (!result.success) {
260+
return { valid: false, error: `Invalid custom claim at index ${i}: ${result.error.issues[0]?.message}` };
261+
}
262+
}
246263
}
247264

248-
const clients = options.allowedClients
249-
.split(',')
250-
.map(s => s.trim())
251-
.filter(Boolean);
252-
if (clients.length === 0) {
253-
return { valid: false, error: 'At least one client value is required' };
265+
// allowedAudience, allowedClients, allowedScopes, customClaims are all optional individually,
266+
// but at least one must be provided
267+
const hasAudience = !!options.allowedAudience?.trim();
268+
const hasClients = !!options.allowedClients?.trim();
269+
const hasScopes = !!options.allowedScopes?.trim();
270+
const hasClaims = !!options.customClaims?.trim();
271+
if (!hasAudience && !hasClients && !hasScopes && !hasClaims) {
272+
return {
273+
valid: false,
274+
error:
275+
'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer',
276+
};
254277
}
255278
}
256279

257-
// Validate agent OAuth credentials
258-
if (options.agentClientId && !options.agentClientSecret) {
259-
return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' };
280+
// Validate OAuth client credentials
281+
if (options.clientId && !options.clientSecret) {
282+
return { valid: false, error: 'Both --client-id and --client-secret must be provided together' };
260283
}
261-
if (options.agentClientSecret && !options.agentClientId) {
262-
return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' };
284+
if (options.clientSecret && !options.clientId) {
285+
return { valid: false, error: 'Both --client-id and --client-secret must be provided together' };
263286
}
264-
if (options.agentClientId && options.authorizerType !== 'CUSTOM_JWT') {
265-
return { valid: false, error: 'Agent OAuth credentials are only valid with CUSTOM_JWT authorizer' };
287+
if (options.clientId && options.authorizerType !== 'CUSTOM_JWT') {
288+
return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' };
266289
}
267290

268291
// Validate exception level if provided

0 commit comments

Comments
 (0)