Skip to content

Commit bf1406c

Browse files
aidandaly24claude
andauthored
fix(gateway): harden inbound auth schema and rename credential flags (#598)
* fix(gateway): harden inbound auth schema and rename credential flags - Enforce HTTPS on OIDC discovery URL in schema and CLI validation - Make allowedAudience/allowedClients optional with at-least-one superRefine constraint (audience, clients, or scopes) - Add .strict() to CustomJwtAuthorizerConfigSchema - Rename --agent-client-id/--agent-client-secret to --client-id/--client-secret across CLI, TUI, and primitives - Add HTTPS validation to TUI discovery URL input - Update deployed-state schema to match (optional audience/clients, add allowedScopes) - Update unit tests for new validation rules and field names Constraint: OIDC spec requires HTTPS for discovery endpoints Rejected: Keep --agent-client-id naming | confusing since these are gateway-level OAuth credentials, not agent credentials Confidence: high Scope-risk: moderate * fix(tui): make allowedClients optional in JWT wizard The schema allows allowedClients to be empty when audience or scopes are provided, but the TUI wizard sub-step still rejected empty input via customValidation. Add allowEmpty and placeholder to match the audience and scopes sub-steps, and remove the now-unused validateCommaSeparated helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f0e1af7 commit bf1406c

File tree

12 files changed

+235
-158
lines changed

12 files changed

+235
-158
lines changed

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: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -220,24 +220,40 @@ describe('validate', () => {
220220
expect(result.error?.includes('Invalid authorizer type')).toBeTruthy();
221221
});
222222

223-
// AC11: CUSTOM_JWT requires discoveryUrl and allowedClients (allowedAudience is optional)
224-
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-
];
223+
// AC11: CUSTOM_JWT requires discoveryUrl
224+
it('returns error for CUSTOM_JWT missing discoveryUrl', () => {
225+
const opts = { ...validGatewayOptionsJwt, discoveryUrl: undefined };
226+
const result = validateAddGatewayOptions(opts);
227+
expect(result.valid).toBe(false);
228+
expect(result.error).toBe('--discovery-url is required for CUSTOM_JWT authorizer');
229+
});
229230

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-
}
231+
// AC11b: at least one of audience/clients/scopes required
232+
it('returns error when all of audience, clients, and scopes are missing', () => {
233+
const opts = {
234+
...validGatewayOptionsJwt,
235+
allowedAudience: undefined,
236+
allowedClients: undefined,
237+
allowedScopes: undefined,
238+
};
239+
const result = validateAddGatewayOptions(opts);
240+
expect(result.valid).toBe(false);
241+
expect(result.error).toContain('At least one of');
242+
});
243+
244+
it('allows CUSTOM_JWT with only allowedScopes', () => {
245+
const opts = {
246+
...validGatewayOptionsJwt,
247+
allowedAudience: undefined,
248+
allowedClients: undefined,
249+
allowedScopes: 'scope1',
250+
};
251+
const result = validateAddGatewayOptions(opts);
252+
expect(result.valid).toBe(true);
236253
});
237254

238-
// AC11b: allowedAudience is optional
239-
it('allows CUSTOM_JWT without allowedAudience', () => {
240-
const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined };
255+
it('allows CUSTOM_JWT with only allowedAudience', () => {
256+
const opts = { ...validGatewayOptionsJwt, allowedClients: undefined, allowedScopes: undefined };
241257
const result = validateAddGatewayOptions(opts);
242258
expect(result.valid).toBe(true);
243259
});
@@ -255,11 +271,19 @@ describe('validate', () => {
255271
expect(result.error?.includes('.well-known/openid-configuration')).toBeTruthy();
256272
});
257273

258-
// AC13: Empty comma-separated clients rejected (audience can be empty)
259-
it('returns error for empty clients', () => {
260-
const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, allowedClients: ' , ' });
274+
it('returns error for HTTP discoveryUrl (HTTPS required)', () => {
275+
const result = validateAddGatewayOptions({
276+
...validGatewayOptionsJwt,
277+
discoveryUrl: 'http://example.com/.well-known/openid-configuration',
278+
});
261279
expect(result.valid).toBe(false);
262-
expect(result.error).toBe('At least one client value is required');
280+
expect(result.error).toBe('Discovery URL must use HTTPS');
281+
});
282+
283+
it('allows CUSTOM_JWT with only allowedClients', () => {
284+
const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined, allowedScopes: undefined };
285+
const result = validateAddGatewayOptions(opts);
286+
expect(result.valid).toBe(true);
263287
});
264288

265289
// AC14: Valid options pass
@@ -268,42 +292,42 @@ describe('validate', () => {
268292
expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true });
269293
});
270294

271-
// AC15: agentClientId and agentClientSecret must be provided together
272-
it('returns error when agentClientId provided without agentClientSecret', () => {
295+
// AC15: clientId and clientSecret must be provided together
296+
it('returns error when clientId provided without clientSecret', () => {
273297
const result = validateAddGatewayOptions({
274298
...validGatewayOptionsJwt,
275-
agentClientId: 'my-client-id',
299+
clientId: 'my-client-id',
276300
});
277301
expect(result.valid).toBe(false);
278-
expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together');
302+
expect(result.error).toBe('Both --client-id and --client-secret must be provided together');
279303
});
280304

281-
it('returns error when agentClientSecret provided without agentClientId', () => {
305+
it('returns error when clientSecret provided without clientId', () => {
282306
const result = validateAddGatewayOptions({
283307
...validGatewayOptionsJwt,
284-
agentClientSecret: 'my-secret',
308+
clientSecret: 'my-secret',
285309
});
286310
expect(result.valid).toBe(false);
287-
expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together');
311+
expect(result.error).toBe('Both --client-id and --client-secret must be provided together');
288312
});
289313

290-
// AC16: agent credentials only valid with CUSTOM_JWT
291-
it('returns error when agent credentials used with non-CUSTOM_JWT authorizer', () => {
314+
// AC16: OAuth credentials only valid with CUSTOM_JWT
315+
it('returns error when OAuth credentials used with non-CUSTOM_JWT authorizer', () => {
292316
const result = validateAddGatewayOptions({
293317
...validGatewayOptionsNone,
294-
agentClientId: 'my-client-id',
295-
agentClientSecret: 'my-secret',
318+
clientId: 'my-client-id',
319+
clientSecret: 'my-secret',
296320
});
297321
expect(result.valid).toBe(false);
298-
expect(result.error).toBe('Agent OAuth credentials are only valid with CUSTOM_JWT authorizer');
322+
expect(result.error).toBe('OAuth client credentials are only valid with CUSTOM_JWT authorizer');
299323
});
300324

301-
// AC17: valid CUSTOM_JWT with agent credentials passes
302-
it('passes for CUSTOM_JWT with agent credentials', () => {
325+
// AC17: valid CUSTOM_JWT with OAuth credentials passes
326+
it('passes for CUSTOM_JWT with OAuth credentials', () => {
303327
const result = validateAddGatewayOptions({
304328
...validGatewayOptionsJwt,
305-
agentClientId: 'my-client-id',
306-
agentClientSecret: 'my-secret',
329+
clientId: 'my-client-id',
330+
clientSecret: 'my-secret',
307331
allowedScopes: 'scope1,scope2',
308332
});
309333
expect(result.valid).toBe(true);

src/cli/commands/add/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export interface AddGatewayOptions {
3737
allowedAudience?: string;
3838
allowedClients?: string;
3939
allowedScopes?: string;
40-
agentClientId?: string;
41-
agentClientSecret?: string;
40+
clientId?: string;
41+
clientSecret?: string;
4242
agents?: string;
4343
semanticSearch?: boolean;
4444
exceptionLevel?: string;

src/cli/commands/add/validate.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio
263263
}
264264

265265
try {
266-
new URL(options.discoveryUrl);
266+
const url = new URL(options.discoveryUrl);
267+
if (url.protocol !== 'https:') {
268+
return { valid: false, error: 'Discovery URL must use HTTPS' };
269+
}
267270
} catch {
268271
return { valid: false, error: 'Discovery URL must be a valid URL' };
269272
}
@@ -272,30 +275,29 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio
272275
return { valid: false, error: `Discovery URL must end with ${OIDC_WELL_KNOWN_SUFFIX}` };
273276
}
274277

275-
// allowedAudience is optional - empty means no audience validation
276-
277-
if (!options.allowedClients) {
278-
return { valid: false, error: '--allowed-clients is required for CUSTOM_JWT authorizer' };
279-
}
280-
281-
const clients = options.allowedClients
282-
.split(',')
283-
.map(s => s.trim())
284-
.filter(Boolean);
285-
if (clients.length === 0) {
286-
return { valid: false, error: 'At least one client value is required' };
278+
// allowedAudience, allowedClients, allowedScopes are all optional individually,
279+
// but at least one must be provided
280+
const hasAudience = !!options.allowedAudience?.trim();
281+
const hasClients = !!options.allowedClients?.trim();
282+
const hasScopes = !!options.allowedScopes?.trim();
283+
if (!hasAudience && !hasClients && !hasScopes) {
284+
return {
285+
valid: false,
286+
error:
287+
'At least one of --allowed-audience, --allowed-clients, or --allowed-scopes must be provided for CUSTOM_JWT authorizer',
288+
};
287289
}
288290
}
289291

290-
// Validate agent OAuth credentials
291-
if (options.agentClientId && !options.agentClientSecret) {
292-
return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' };
292+
// Validate OAuth client credentials
293+
if (options.clientId && !options.clientSecret) {
294+
return { valid: false, error: 'Both --client-id and --client-secret must be provided together' };
293295
}
294-
if (options.agentClientSecret && !options.agentClientId) {
295-
return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' };
296+
if (options.clientSecret && !options.clientId) {
297+
return { valid: false, error: 'Both --client-id and --client-secret must be provided together' };
296298
}
297-
if (options.agentClientId && options.authorizerType !== 'CUSTOM_JWT') {
298-
return { valid: false, error: 'Agent OAuth credentials are only valid with CUSTOM_JWT authorizer' };
299+
if (options.clientId && options.authorizerType !== 'CUSTOM_JWT') {
300+
return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' };
299301
}
300302

301303
// Validate exception level if provided

src/cli/primitives/GatewayPrimitive.ts

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export interface AddGatewayOptions {
2323
allowedAudience?: string;
2424
allowedClients?: string;
2525
allowedScopes?: string;
26-
agentClientId?: string;
27-
agentClientSecret?: string;
26+
clientId?: string;
27+
clientSecret?: string;
2828
agents?: string;
2929
enableSemanticSearch?: boolean;
3030
exceptionLevel?: string;
@@ -157,8 +157,8 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
157157
.option('--allowed-audience <audience>', 'Comma-separated allowed audiences (for CUSTOM_JWT)')
158158
.option('--allowed-clients <clients>', 'Comma-separated allowed client IDs (for CUSTOM_JWT)')
159159
.option('--allowed-scopes <scopes>', 'Comma-separated allowed scopes (for CUSTOM_JWT)')
160-
.option('--agent-client-id <id>', 'Agent OAuth client ID')
161-
.option('--agent-client-secret <secret>', 'Agent OAuth client secret')
160+
.option('--client-id <id>', 'OAuth client ID for gateway bearer token')
161+
.option('--client-secret <secret>', 'OAuth client secret')
162162
.option('--agents <agents>', 'Comma-separated agent names')
163163
.option('--no-semantic-search', 'Disable semantic search for tool discovery')
164164
.option('--exception-level <level>', 'Exception verbosity level', 'NONE')
@@ -191,8 +191,8 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
191191
allowedAudience: cliOptions.allowedAudience,
192192
allowedClients: cliOptions.allowedClients,
193193
allowedScopes: cliOptions.allowedScopes,
194-
agentClientId: cliOptions.agentClientId,
195-
agentClientSecret: cliOptions.agentClientSecret,
194+
clientId: cliOptions.clientId,
195+
clientSecret: cliOptions.clientSecret,
196196
agents: cliOptions.agents,
197197
enableSemanticSearch: cliOptions.semanticSearch !== false,
198198
exceptionLevel: cliOptions.exceptionLevel,
@@ -303,30 +303,32 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
303303
};
304304

305305
if (options.authorizerType === 'CUSTOM_JWT' && options.discoveryUrl) {
306+
const allowedAudience = options.allowedAudience
307+
? options.allowedAudience
308+
.split(',')
309+
.map(s => s.trim())
310+
.filter(Boolean)
311+
: undefined;
312+
const allowedClients = options.allowedClients
313+
? options.allowedClients
314+
.split(',')
315+
.map(s => s.trim())
316+
.filter(Boolean)
317+
: undefined;
318+
const allowedScopes = options.allowedScopes
319+
? options.allowedScopes
320+
.split(',')
321+
.map(s => s.trim())
322+
.filter(Boolean)
323+
: undefined;
324+
306325
config.jwtConfig = {
307326
discoveryUrl: options.discoveryUrl,
308-
allowedAudience: options.allowedAudience
309-
? options.allowedAudience
310-
.split(',')
311-
.map(s => s.trim())
312-
.filter(Boolean)
313-
: [],
314-
allowedClients: options.allowedClients
315-
? options.allowedClients
316-
.split(',')
317-
.map(s => s.trim())
318-
.filter(Boolean)
319-
: [],
320-
...(options.allowedScopes
321-
? {
322-
allowedScopes: options.allowedScopes
323-
.split(',')
324-
.map(s => s.trim())
325-
.filter(Boolean),
326-
}
327-
: {}),
328-
...(options.agentClientId ? { agentClientId: options.agentClientId } : {}),
329-
...(options.agentClientSecret ? { agentClientSecret: options.agentClientSecret } : {}),
327+
...(allowedAudience?.length ? { allowedAudience } : {}),
328+
...(allowedClients?.length ? { allowedClients } : {}),
329+
...(allowedScopes?.length ? { allowedScopes } : {}),
330+
...(options.clientId ? { clientId: options.clientId } : {}),
331+
...(options.clientSecret ? { clientSecret: options.clientSecret } : {}),
330332
};
331333
}
332334

@@ -374,8 +376,8 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
374376
mcpSpec.agentCoreGateways.push(gateway);
375377
await this.configIO.writeMcpSpec(mcpSpec);
376378

377-
// Auto-create OAuth credential if agent client credentials are provided
378-
if (config.jwtConfig?.agentClientId && config.jwtConfig?.agentClientSecret) {
379+
// Auto-create OAuth credential if client credentials are provided
380+
if (config.jwtConfig?.clientId && config.jwtConfig?.clientSecret) {
379381
await this.createManagedOAuthCredential(config.name, config.jwtConfig);
380382
}
381383

@@ -408,10 +410,9 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
408410
});
409411
await this.writeProjectSpec(project);
410412

411-
// Write client ID and client secret to .env
412-
const envVarPrefix = computeDefaultCredentialEnvVarName(credentialName);
413-
await setEnvVar(`${envVarPrefix}_CLIENT_ID`, jwtConfig.agentClientId!);
414-
await setEnvVar(`${envVarPrefix}_CLIENT_SECRET`, jwtConfig.agentClientSecret!);
413+
// Write client secret to .env
414+
const envVarName = computeDefaultCredentialEnvVarName(credentialName);
415+
await setEnvVar(envVarName, jwtConfig.clientSecret!);
415416
}
416417

417418
/**
@@ -425,11 +426,9 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
425426
return {
426427
customJwtAuthorizer: {
427428
discoveryUrl: config.jwtConfig.discoveryUrl,
428-
allowedAudience: config.jwtConfig.allowedAudience,
429-
allowedClients: config.jwtConfig.allowedClients,
430-
...(config.jwtConfig.allowedScopes && config.jwtConfig.allowedScopes.length > 0
431-
? { allowedScopes: config.jwtConfig.allowedScopes }
432-
: {}),
429+
...(config.jwtConfig.allowedAudience?.length ? { allowedAudience: config.jwtConfig.allowedAudience } : {}),
430+
...(config.jwtConfig.allowedClients?.length ? { allowedClients: config.jwtConfig.allowedClients } : {}),
431+
...(config.jwtConfig.allowedScopes?.length ? { allowedScopes: config.jwtConfig.allowedScopes } : {}),
433432
},
434433
};
435434
}

src/cli/tui/hooks/useCreateMcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export function useCreateGateway() {
3131
allowedAudience: config.jwtConfig?.allowedAudience?.join(','),
3232
allowedClients: config.jwtConfig?.allowedClients?.join(','),
3333
allowedScopes: config.jwtConfig?.allowedScopes?.join(','),
34-
agentClientId: config.jwtConfig?.agentClientId,
35-
agentClientSecret: config.jwtConfig?.agentClientSecret,
34+
clientId: config.jwtConfig?.clientId,
35+
clientSecret: config.jwtConfig?.clientSecret,
3636
enableSemanticSearch: config.enableSemanticSearch,
3737
exceptionLevel: config.exceptionLevel,
3838
policyEngine: config.policyEngineConfiguration?.policyEngineName,

0 commit comments

Comments
 (0)