diff --git a/src/cli/commands/add/__tests__/add-gateway.test.ts b/src/cli/commands/add/__tests__/add-gateway.test.ts index 50d0f784..47be74a2 100644 --- a/src/cli/commands/add/__tests__/add-gateway.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway.test.ts @@ -167,9 +167,9 @@ describe('add gateway command', () => { 'client1', '--allowed-scopes', 'scope1,scope2', - '--agent-client-id', + '--client-id', 'agent-cid', - '--agent-client-secret', + '--client-secret', 'agent-secret', '--json', ], diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 52006aa6..ce09f4b9 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -220,24 +220,40 @@ describe('validate', () => { expect(result.error?.includes('Invalid authorizer type')).toBeTruthy(); }); - // AC11: CUSTOM_JWT requires discoveryUrl and allowedClients (allowedAudience is optional) - it('returns error for CUSTOM_JWT missing required fields', () => { - const jwtFields: { field: keyof AddGatewayOptions; error: string }[] = [ - { field: 'discoveryUrl', error: '--discovery-url is required for CUSTOM_JWT authorizer' }, - { field: 'allowedClients', error: '--allowed-clients is required for CUSTOM_JWT authorizer' }, - ]; + // AC11: CUSTOM_JWT requires discoveryUrl + it('returns error for CUSTOM_JWT missing discoveryUrl', () => { + const opts = { ...validGatewayOptionsJwt, discoveryUrl: undefined }; + const result = validateAddGatewayOptions(opts); + expect(result.valid).toBe(false); + expect(result.error).toBe('--discovery-url is required for CUSTOM_JWT authorizer'); + }); - for (const { field, error } of jwtFields) { - const opts = { ...validGatewayOptionsJwt, [field]: undefined }; - const result = validateAddGatewayOptions(opts); - expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); - expect(result.error).toBe(error); - } + // AC11b: at least one of audience/clients/scopes required + it('returns error when all of audience, clients, and scopes are missing', () => { + const opts = { + ...validGatewayOptionsJwt, + allowedAudience: undefined, + allowedClients: undefined, + allowedScopes: undefined, + }; + const result = validateAddGatewayOptions(opts); + expect(result.valid).toBe(false); + expect(result.error).toContain('At least one of'); + }); + + it('allows CUSTOM_JWT with only allowedScopes', () => { + const opts = { + ...validGatewayOptionsJwt, + allowedAudience: undefined, + allowedClients: undefined, + allowedScopes: 'scope1', + }; + const result = validateAddGatewayOptions(opts); + expect(result.valid).toBe(true); }); - // AC11b: allowedAudience is optional - it('allows CUSTOM_JWT without allowedAudience', () => { - const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined }; + it('allows CUSTOM_JWT with only allowedAudience', () => { + const opts = { ...validGatewayOptionsJwt, allowedClients: undefined, allowedScopes: undefined }; const result = validateAddGatewayOptions(opts); expect(result.valid).toBe(true); }); @@ -255,11 +271,19 @@ describe('validate', () => { expect(result.error?.includes('.well-known/openid-configuration')).toBeTruthy(); }); - // AC13: Empty comma-separated clients rejected (audience can be empty) - it('returns error for empty clients', () => { - const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, allowedClients: ' , ' }); + it('returns error for HTTP discoveryUrl (HTTPS required)', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + discoveryUrl: 'http://example.com/.well-known/openid-configuration', + }); expect(result.valid).toBe(false); - expect(result.error).toBe('At least one client value is required'); + expect(result.error).toBe('Discovery URL must use HTTPS'); + }); + + it('allows CUSTOM_JWT with only allowedClients', () => { + const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined, allowedScopes: undefined }; + const result = validateAddGatewayOptions(opts); + expect(result.valid).toBe(true); }); // AC14: Valid options pass @@ -268,42 +292,42 @@ describe('validate', () => { expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true }); }); - // AC15: agentClientId and agentClientSecret must be provided together - it('returns error when agentClientId provided without agentClientSecret', () => { + // AC15: clientId and clientSecret must be provided together + it('returns error when clientId provided without clientSecret', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, - agentClientId: 'my-client-id', + clientId: 'my-client-id', }); expect(result.valid).toBe(false); - expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); + expect(result.error).toBe('Both --client-id and --client-secret must be provided together'); }); - it('returns error when agentClientSecret provided without agentClientId', () => { + it('returns error when clientSecret provided without clientId', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, - agentClientSecret: 'my-secret', + clientSecret: 'my-secret', }); expect(result.valid).toBe(false); - expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); + expect(result.error).toBe('Both --client-id and --client-secret must be provided together'); }); - // AC16: agent credentials only valid with CUSTOM_JWT - it('returns error when agent credentials used with non-CUSTOM_JWT authorizer', () => { + // AC16: OAuth credentials only valid with CUSTOM_JWT + it('returns error when OAuth credentials used with non-CUSTOM_JWT authorizer', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsNone, - agentClientId: 'my-client-id', - agentClientSecret: 'my-secret', + clientId: 'my-client-id', + clientSecret: 'my-secret', }); expect(result.valid).toBe(false); - expect(result.error).toBe('Agent OAuth credentials are only valid with CUSTOM_JWT authorizer'); + expect(result.error).toBe('OAuth client credentials are only valid with CUSTOM_JWT authorizer'); }); - // AC17: valid CUSTOM_JWT with agent credentials passes - it('passes for CUSTOM_JWT with agent credentials', () => { + // AC17: valid CUSTOM_JWT with OAuth credentials passes + it('passes for CUSTOM_JWT with OAuth credentials', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, - agentClientId: 'my-client-id', - agentClientSecret: 'my-secret', + clientId: 'my-client-id', + clientSecret: 'my-secret', allowedScopes: 'scope1,scope2', }); expect(result.valid).toBe(true); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 682fcfd7..65a86e3d 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -34,8 +34,8 @@ export interface AddGatewayOptions { allowedAudience?: string; allowedClients?: string; allowedScopes?: string; - agentClientId?: string; - agentClientSecret?: string; + clientId?: string; + clientSecret?: string; agents?: string; semanticSearch?: boolean; exceptionLevel?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 46de1c7a..60da22cd 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -230,7 +230,10 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio } try { - new URL(options.discoveryUrl); + const url = new URL(options.discoveryUrl); + if (url.protocol !== 'https:') { + return { valid: false, error: 'Discovery URL must use HTTPS' }; + } } catch { return { valid: false, error: 'Discovery URL must be a valid URL' }; } @@ -239,30 +242,29 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio return { valid: false, error: `Discovery URL must end with ${OIDC_WELL_KNOWN_SUFFIX}` }; } - // allowedAudience is optional - empty means no audience validation - - if (!options.allowedClients) { - return { valid: false, error: '--allowed-clients is required for CUSTOM_JWT authorizer' }; - } - - const clients = options.allowedClients - .split(',') - .map(s => s.trim()) - .filter(Boolean); - if (clients.length === 0) { - return { valid: false, error: 'At least one client value is required' }; + // allowedAudience, allowedClients, allowedScopes are all optional individually, + // but at least one must be provided + const hasAudience = !!options.allowedAudience?.trim(); + const hasClients = !!options.allowedClients?.trim(); + const hasScopes = !!options.allowedScopes?.trim(); + if (!hasAudience && !hasClients && !hasScopes) { + return { + valid: false, + error: + 'At least one of --allowed-audience, --allowed-clients, or --allowed-scopes must be provided for CUSTOM_JWT authorizer', + }; } } - // Validate agent OAuth credentials - if (options.agentClientId && !options.agentClientSecret) { - return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' }; + // Validate OAuth client credentials + if (options.clientId && !options.clientSecret) { + return { valid: false, error: 'Both --client-id and --client-secret must be provided together' }; } - if (options.agentClientSecret && !options.agentClientId) { - return { valid: false, error: 'Both --agent-client-id and --agent-client-secret must be provided together' }; + if (options.clientSecret && !options.clientId) { + return { valid: false, error: 'Both --client-id and --client-secret must be provided together' }; } - if (options.agentClientId && options.authorizerType !== 'CUSTOM_JWT') { - return { valid: false, error: 'Agent OAuth credentials are only valid with CUSTOM_JWT authorizer' }; + if (options.clientId && options.authorizerType !== 'CUSTOM_JWT') { + return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' }; } // Validate exception level if provided diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 4f7cd2b7..427809c2 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -23,8 +23,8 @@ export interface AddGatewayOptions { allowedAudience?: string; allowedClients?: string; allowedScopes?: string; - agentClientId?: string; - agentClientSecret?: string; + clientId?: string; + clientSecret?: string; agents?: string; enableSemanticSearch?: boolean; exceptionLevel?: string; @@ -157,8 +157,8 @@ export class GatewayPrimitive extends BasePrimitive', 'Comma-separated allowed audiences (for CUSTOM_JWT)') .option('--allowed-clients ', 'Comma-separated allowed client IDs (for CUSTOM_JWT)') .option('--allowed-scopes ', 'Comma-separated allowed scopes (for CUSTOM_JWT)') - .option('--agent-client-id ', 'Agent OAuth client ID') - .option('--agent-client-secret ', 'Agent OAuth client secret') + .option('--client-id ', 'OAuth client ID for gateway bearer token') + .option('--client-secret ', 'OAuth client secret') .option('--agents ', 'Comma-separated agent names') .option('--no-semantic-search', 'Disable semantic search for tool discovery') .option('--exception-level ', 'Exception verbosity level', 'NONE') @@ -191,8 +191,8 @@ export class GatewayPrimitive extends BasePrimitive s.trim()) + .filter(Boolean) + : undefined; + const allowedClients = options.allowedClients + ? options.allowedClients + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined; + const allowedScopes = options.allowedScopes + ? options.allowedScopes + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined; + config.jwtConfig = { discoveryUrl: options.discoveryUrl, - allowedAudience: options.allowedAudience - ? options.allowedAudience - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : [], - allowedClients: options.allowedClients - ? options.allowedClients - .split(',') - .map(s => s.trim()) - .filter(Boolean) - : [], - ...(options.allowedScopes - ? { - allowedScopes: options.allowedScopes - .split(',') - .map(s => s.trim()) - .filter(Boolean), - } - : {}), - ...(options.agentClientId ? { agentClientId: options.agentClientId } : {}), - ...(options.agentClientSecret ? { agentClientSecret: options.agentClientSecret } : {}), + ...(allowedAudience?.length ? { allowedAudience } : {}), + ...(allowedClients?.length ? { allowedClients } : {}), + ...(allowedScopes?.length ? { allowedScopes } : {}), + ...(options.clientId ? { clientId: options.clientId } : {}), + ...(options.clientSecret ? { clientSecret: options.clientSecret } : {}), }; } @@ -374,8 +376,8 @@ export class GatewayPrimitive extends BasePrimitive 0 - ? { allowedScopes: config.jwtConfig.allowedScopes } - : {}), + ...(config.jwtConfig.allowedAudience?.length ? { allowedAudience: config.jwtConfig.allowedAudience } : {}), + ...(config.jwtConfig.allowedClients?.length ? { allowedClients: config.jwtConfig.allowedClients } : {}), + ...(config.jwtConfig.allowedScopes?.length ? { allowedScopes: config.jwtConfig.allowedScopes } : {}), }, }; } diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 7073d5b4..b3d999d7 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -31,8 +31,8 @@ export function useCreateGateway() { allowedAudience: config.jwtConfig?.allowedAudience?.join(','), allowedClients: config.jwtConfig?.allowedClients?.join(','), allowedScopes: config.jwtConfig?.allowedScopes?.join(','), - agentClientId: config.jwtConfig?.agentClientId, - agentClientSecret: config.jwtConfig?.agentClientSecret, + clientId: config.jwtConfig?.clientId, + clientSecret: config.jwtConfig?.clientSecret, enableSemanticSearch: config.enableSemanticSearch, exceptionLevel: config.exceptionLevel, policyEngine: config.policyEngineConfiguration?.policyEngineName, diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 7bfbd6e2..cef31420 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -47,13 +47,13 @@ export function AddGatewayScreen({ }: AddGatewayScreenProps) { const wizard = useAddGatewayWizard(unassignedTargets.length, existingPolicyEngines.length); - // JWT config sub-step tracking (0=discoveryUrl, 1=audience, 2=clients, 3=scopes, 4=agentClientId, 5=agentClientSecret) + // JWT config sub-step tracking (0=discoveryUrl, 1=audience, 2=clients, 3=scopes, 4=clientId, 5=clientSecret) const [jwtSubStep, setJwtSubStep] = useState(0); const [jwtDiscoveryUrl, setJwtDiscoveryUrl] = useState(''); const [jwtAudience, setJwtAudience] = useState(''); const [jwtClients, setJwtClients] = useState(''); const [jwtScopes, setJwtScopes] = useState(''); - const [jwtAgentClientId, setJwtAgentClientId] = useState(''); + const [jwtClientId, setJwtClientId] = useState(''); const unassignedTargetItems: SelectableItem[] = useMemo( () => unassignedTargets.map(name => ({ id: name, title: name })), @@ -193,12 +193,12 @@ export function AddGatewayScreen({ setJwtSubStep(4); }; - const handleJwtAgentClientId = (clientId: string) => { - setJwtAgentClientId(clientId); + const handleJwtClientId = (clientId: string) => { + setJwtClientId(clientId); setJwtSubStep(5); }; - const handleJwtAgentClientSecret = (clientSecret: string) => { + const handleJwtClientSecret = (clientSecret: string) => { const audienceList = jwtAudience .split(',') .map(s => s.trim()) @@ -214,10 +214,10 @@ export function AddGatewayScreen({ wizard.setJwtConfig({ discoveryUrl: jwtDiscoveryUrl, - allowedAudience: audienceList, - allowedClients: clientsList, + ...(audienceList.length > 0 ? { allowedAudience: audienceList } : {}), + ...(clientsList.length > 0 ? { allowedClients: clientsList } : {}), ...(scopesList.length > 0 ? { allowedScopes: scopesList } : {}), - ...(jwtAgentClientId ? { agentClientId: jwtAgentClientId, agentClientSecret: clientSecret } : {}), + ...(jwtClientId ? { clientId: jwtClientId, clientSecret } : {}), }); setJwtSubStep(0); @@ -280,8 +280,8 @@ export function AddGatewayScreen({ onAudience={handleJwtAudience} onClients={handleJwtClients} onScopes={handleJwtScopes} - onAgentClientId={handleJwtAgentClientId} - onAgentClientSecret={handleJwtAgentClientSecret} + onClientId={handleJwtClientId} + onClientSecret={handleJwtClientSecret} onCancel={handleJwtCancel} /> )} @@ -349,13 +349,17 @@ export function AddGatewayScreen({ ...(wizard.config.authorizerType === 'CUSTOM_JWT' && wizard.config.jwtConfig ? [ { label: 'Discovery URL', value: wizard.config.jwtConfig.discoveryUrl }, - { label: 'Allowed Audience', value: wizard.config.jwtConfig.allowedAudience.join(', ') }, - { label: 'Allowed Clients', value: wizard.config.jwtConfig.allowedClients.join(', ') }, + ...(wizard.config.jwtConfig.allowedAudience?.length + ? [{ label: 'Allowed Audience', value: wizard.config.jwtConfig.allowedAudience.join(', ') }] + : []), + ...(wizard.config.jwtConfig.allowedClients?.length + ? [{ label: 'Allowed Clients', value: wizard.config.jwtConfig.allowedClients.join(', ') }] + : []), ...(wizard.config.jwtConfig.allowedScopes?.length ? [{ label: 'Allowed Scopes', value: wizard.config.jwtConfig.allowedScopes.join(', ') }] : []), - ...(wizard.config.jwtConfig.agentClientId - ? [{ label: 'Agent Credential', value: computeManagedOAuthCredentialName(wizard.config.name) }] + ...(wizard.config.jwtConfig.clientId + ? [{ label: 'Gateway Credential', value: computeManagedOAuthCredentialName(wizard.config.name) }] : []), ] : []), @@ -388,25 +392,15 @@ interface JwtConfigInputProps { onAudience: (audience: string) => void; onClients: (clients: string) => void; onScopes: (scopes: string) => void; - onAgentClientId: (clientId: string) => void; - onAgentClientSecret: (clientSecret: string) => void; + onClientId: (clientId: string) => void; + onClientSecret: (clientSecret: string) => void; onCancel: () => void; } /** OIDC well-known suffix for validation */ const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; -/** Validates comma-separated list has at least one non-empty value */ -function validateCommaSeparatedList(value: string, fieldName: string): true | string { - const items = value - .split(',') - .map(s => s.trim()) - .filter(Boolean); - if (items.length === 0) { - return `At least one ${fieldName} is required`; - } - return true; -} + function JwtConfigInput({ subStep, @@ -414,8 +408,8 @@ function JwtConfigInput({ onAudience, onClients, onScopes, - onAgentClientId, - onAgentClientSecret, + onClientId, + onClientSecret, onCancel, }: JwtConfigInputProps) { const totalSteps = 6; @@ -433,11 +427,15 @@ function JwtConfigInput({ onSubmit={onDiscoveryUrl} onCancel={onCancel} customValidation={value => { + let parsed: URL; try { - new URL(value); + parsed = new URL(value); } catch { return 'Must be a valid URL'; } + if (parsed.protocol !== 'https:') { + return 'Discovery URL must use HTTPS'; + } if (!value.endsWith(OIDC_WELL_KNOWN_SUFFIX)) { return `URL must end with '${OIDC_WELL_KNOWN_SUFFIX}'`; } @@ -458,10 +456,11 @@ function JwtConfigInput({ {subStep === 2 && ( validateCommaSeparatedList(value, 'client')} + allowEmpty /> )} {subStep === 3 && ( @@ -476,16 +475,16 @@ function JwtConfigInput({ )} {subStep === 4 && ( )} {subStep === 5 && ( value.trim().length > 0 || 'Client secret is required'} revealChars={4} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index de4c19aa..f834dd0c 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -32,11 +32,11 @@ export interface AddGatewayConfig { /** JWT authorizer configuration (when authorizerType is 'CUSTOM_JWT') */ jwtConfig?: { discoveryUrl: string; - allowedAudience: string[]; - allowedClients: string[]; + allowedAudience?: string[]; + allowedClients?: string[]; allowedScopes?: string[]; - agentClientId?: string; - agentClientSecret?: string; + clientId?: string; + clientSecret?: string; }; /** Selected unassigned targets to include in this gateway */ selectedTargets?: string[]; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index ff0fb6d4..00137862 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -81,11 +81,11 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0, policyEngineCoun const setJwtConfig = useCallback( (jwtConfig: { discoveryUrl: string; - allowedAudience: string[]; - allowedClients: string[]; + allowedAudience?: string[]; + allowedClients?: string[]; allowedScopes?: string[]; - agentClientId?: string; - agentClientSecret?: string; + clientId?: string; + clientSecret?: string; }) => { setConfig(c => ({ ...c, diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 517683a2..741181f8 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -85,18 +85,49 @@ describe('CustomJwtAuthorizerConfigSchema', () => { expect(result.success).toBe(false); }); - it('rejects empty allowedClients', () => { + it('rejects HTTP discovery URL (HTTPS required)', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ ...validConfig, - allowedClients: [], + discoveryUrl: 'http://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', }); expect(result.success).toBe(false); }); - it('accepts empty allowedAudience (no audience validation)', () => { + it('rejects unknown fields (strict)', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ ...validConfig, - allowedAudience: [], + unknownField: 'not allowed', + }); + expect(result.success).toBe(false); + }); + + it('accepts config with only allowedScopes (audience and clients optional)', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + discoveryUrl: validConfig.discoveryUrl, + allowedScopes: ['read', 'write'], + }); + expect(result.success).toBe(true); + }); + + it('rejects config with no audience, clients, or scopes', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + discoveryUrl: validConfig.discoveryUrl, + }); + expect(result.success).toBe(false); + }); + + it('accepts config with only allowedClients', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + discoveryUrl: validConfig.discoveryUrl, + allowedClients: ['client-id-1'], + }); + expect(result.success).toBe(true); + }); + + it('accepts config with only allowedAudience', () => { + const result = CustomJwtAuthorizerConfigSchema.safeParse({ + discoveryUrl: validConfig.discoveryUrl, + allowedAudience: ['aud-1'], }); expect(result.success).toBe(true); }); diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 4c1991b7..4d9389a8 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -86,8 +86,9 @@ export const ExternallyManagedResourceSchema = z.object({ export type ExternallyManagedResource = z.infer; export const CustomJwtAuthorizerSchema = ExternallyManagedResourceSchema.extend({ - allowedAudience: z.array(z.string()), - allowedClients: z.array(z.string()), + allowedAudience: z.array(z.string()).optional(), + allowedClients: z.array(z.string()).optional(), + allowedScopes: z.array(z.string()).optional(), discoveryUrl: z.string(), }); diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 6d826f9f..3294baa1 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -36,6 +36,9 @@ const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; const OidcDiscoveryUrlSchema = z .string() .url('Must be a valid URL') + .refine(url => url.startsWith('https://'), { + message: 'OIDC discovery URL must use HTTPS', + }) .refine(url => url.endsWith(OIDC_WELL_KNOWN_SUFFIX), { message: `OIDC discovery URL must end with '${OIDC_WELL_KNOWN_SUFFIX}'`, }); @@ -43,16 +46,34 @@ const OidcDiscoveryUrlSchema = z /** * Custom JWT authorizer configuration. * Used when authorizerType is 'CUSTOM_JWT'. + * + * At least one of allowedAudience, allowedClients, or allowedScopes + * must be provided. Only discoveryUrl is unconditionally required. */ -export const CustomJwtAuthorizerConfigSchema = z.object({ - /** OIDC discovery URL (e.g., https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/openid-configuration) */ - discoveryUrl: OidcDiscoveryUrlSchema, - /** List of allowed audiences (typically client IDs). Empty array means no audience validation. */ - allowedAudience: z.array(z.string().min(1)), - /** List of allowed client IDs */ - allowedClients: z.array(z.string().min(1)).min(1), - allowedScopes: z.array(z.string().min(1)).optional(), -}); +export const CustomJwtAuthorizerConfigSchema = z + .object({ + /** OIDC discovery URL (e.g., https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/openid-configuration) */ + discoveryUrl: OidcDiscoveryUrlSchema, + /** List of allowed audiences (typically client IDs) */ + allowedAudience: z.array(z.string().min(1)).optional(), + /** List of allowed client IDs */ + allowedClients: z.array(z.string().min(1)).optional(), + /** List of allowed scopes */ + allowedScopes: z.array(z.string().min(1)).optional(), + }) + .strict() + .superRefine((data, ctx) => { + const hasAudience = data.allowedAudience && data.allowedAudience.length > 0; + const hasClients = data.allowedClients && data.allowedClients.length > 0; + const hasScopes = data.allowedScopes && data.allowedScopes.length > 0; + + if (!hasAudience && !hasClients && !hasScopes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one of allowedAudience, allowedClients, or allowedScopes must be provided', + }); + } + }); export type CustomJwtAuthorizerConfig = z.infer;