From 80059788940bee160eed447888dc344a766d5b55 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Sun, 22 Mar 2026 23:10:15 -0400 Subject: [PATCH 1/7] feat(gateway): add custom claims validation and TUI wizard for JWT auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add custom JWT claims validation support and a full TUI wizard flow for configuring Custom JWT gateway authorization. Schema: - Add ClaimMatchOperator, ClaimMatchValue, InboundTokenClaimValueType, and CustomClaimValidation schemas with strict validation - Add customClaims to CustomJwtAuthorizerConfigSchema and deployed-state - Add --custom-claims CLI flag with JSON parsing and validation TUI Wizard: - Expand JWT config flow with custom claims manager (add/edit/done) - Add claim name, operator, value, and value type sub-steps - Show human-readable claim summary in confirm review - Make client credentials optional (skip with empty Enter) Testing: - Add AddGatewayJwtConfig.test.tsx — full TUI component tests - Add finishJwtConfig.test.ts — unit tests for config assembly - Extend useAddGatewayWizard.test.tsx with JWT + custom claims flows - Add GatewayPrimitive.test.ts for custom claims round-trip - Extend validate.test.ts with custom claims validation cases - Add TUI integration test (add-gateway-jwt.test.ts) Constraint: Stacked on fix/inbound-auth-hardening (#598) Confidence: high Scope-risk: moderate --- integ-tests/tui/add-gateway-jwt.test.ts | 415 ++++++++++ integ-tests/tui/setup.ts | 6 + .../commands/add/__tests__/validate.test.ts | 126 ++- src/cli/commands/add/types.ts | 1 + src/cli/commands/add/validate.ts | 27 +- src/cli/primitives/GatewayPrimitive.ts | 11 + .../__tests__/GatewayPrimitive.test.ts | 72 ++ src/cli/tui/hooks/useCreateMcp.ts | 1 + src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 717 ++++++++++++++++-- .../__tests__/AddGatewayJwtConfig.test.tsx | 652 ++++++++++++++++ .../mcp/__tests__/finishJwtConfig.test.ts | 66 ++ .../__tests__/useAddGatewayWizard.test.tsx | 258 ++++++- src/cli/tui/screens/mcp/types.ts | 2 + .../tui/screens/mcp/useAddGatewayWizard.ts | 3 +- src/schema/schemas/__tests__/mcp.test.ts | 84 +- src/schema/schemas/deployed-state.ts | 2 + src/schema/schemas/mcp.ts | 47 +- 17 files changed, 2333 insertions(+), 157 deletions(-) create mode 100644 integ-tests/tui/add-gateway-jwt.test.ts create mode 100644 integ-tests/tui/setup.ts create mode 100644 src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx create mode 100644 src/cli/tui/screens/mcp/__tests__/finishJwtConfig.test.ts diff --git a/integ-tests/tui/add-gateway-jwt.test.ts b/integ-tests/tui/add-gateway-jwt.test.ts new file mode 100644 index 000000000..e18ea5e22 --- /dev/null +++ b/integ-tests/tui/add-gateway-jwt.test.ts @@ -0,0 +1,415 @@ +/** + * TUI Integration Test: Add Gateway JWT Configuration Flow + * + * Drives the full "Add Gateway" wizard through the CUSTOM_JWT authorizer path + * using the TuiSession API. Captures screenshots at key steps and verifies + * each screen renders correctly. + * + * Exercises: + * - Navigation from HelpScreen -> Add Resource -> Gateway + * - Gateway name input (accept default) + * - Authorizer type selection (CUSTOM_JWT) + * - JWT Discovery URL input + * - JWT constraint multi-select (Audiences + Custom Claims) + * - Audience value input + * - Custom claim form (tabbed fields with cycling selects) + * - Client ID skip (empty = skip OAuth credentials) + * - Advanced config defaults + * - Confirm review screen content verification + */ +import { DARK_THEME, TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import type { MinimalProjectDirResult } from './helpers.js'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// Paths & Constants +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CLI_DIST = join(__dirname, '..', '..', 'dist', 'cli', 'index.mjs'); +const SCREENSHOTS_DIR = '/tmp/tui-test-jwt/screenshots'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function screenshotPath(name: string, ext = 'txt'): string { + return join(SCREENSHOTS_DIR, `${name}.${ext}`); +} + +function saveTextScreenshot(session: TuiSession, name: string): string { + const screen = session.readScreen({ numbered: true }); + const nonEmpty = screen.lines.filter((l: string) => l.trim() !== ''); + const { cols, rows } = screen.dimensions; + const header = `Screenshot: ${name} (${cols}x${rows})`; + const border = '='.repeat(Math.max(header.length, 60)); + const text = `${border}\n${header}\n${border}\n${nonEmpty.join('\n')}\n${border}\n`; + const path = screenshotPath(name); + writeFileSync(path, text, 'utf-8'); + return path; +} + +function getScreenText(session: TuiSession): string { + return session.readScreen().lines.join('\n'); +} + +async function safeWaitFor(session: TuiSession, pattern: string | RegExp, timeoutMs = 10_000): Promise { + try { + await session.waitFor(pattern, timeoutMs); + return true; + } catch (err) { + if (err instanceof WaitForTimeoutError) { + return false; + } + throw err; + } +} + +/** Small delay for UI settling between interactions. */ +const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)); + +// --------------------------------------------------------------------------- +// Test Suite +// --------------------------------------------------------------------------- + +describe('Add Gateway JWT Flow', () => { + let session: TuiSession; + let projectDir: MinimalProjectDirResult; + + beforeAll(async () => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + + // Create a minimal project directory + projectDir = await createMinimalProjectDir({ projectName: 'jwt-test-project' }); + + // Launch the TUI using process.execPath for the absolute node binary path. + // node-pty uses posix_spawnp which cannot resolve bare 'node' when it is + // managed by a version manager (mise, nvm, etc.) outside the default PATH. + session = await TuiSession.launch({ + command: process.execPath, + args: [CLI_DIST], + cwd: projectDir.dir, + cols: 120, + rows: 35, + }); + }); + + afterAll(async () => { + if (session?.alive) { + await session.close(); + } + if (projectDir) { + await projectDir.cleanup(); + } + }); + + it('Step 1: reaches HelpScreen with Commands', async () => { + const found = await safeWaitFor(session, 'Commands', 15_000); + if (!found) { + saveTextScreenshot(session, '01-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '01-helpscreen'); + }); + + it('Step 1b: filters to "add" and opens Add Resource screen', async () => { + await session.sendKeys('add'); + await settle(); + saveTextScreenshot(session, '01b-filtered-add'); + + await session.sendSpecialKey('enter'); + const found = await safeWaitFor(session, 'Add Resource', 5_000); + if (!found) { + saveTextScreenshot(session, '01c-add-resource-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '01c-add-resource'); + }); + + it('Step 1c: navigates to Gateway and enters the wizard', async () => { + // Add Resource list order: + // 0: Agent, 1: Memory, 2: Identity, 3: Evaluator, + // 4: Online Eval Config, 5: Gateway, 6: Gateway Target + for (let i = 0; i < 5; i++) { + await session.sendSpecialKey('down'); + } + await settle(); + saveTextScreenshot(session, '01d-gateway-highlighted'); + + const text = getScreenText(session); + expect(text).toContain('Gateway'); + + await session.sendSpecialKey('enter'); + const found = await safeWaitFor(session, 'Name', 5_000); + if (!found) { + saveTextScreenshot(session, '01e-gateway-name-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '02-gateway-name'); + }); + + it('Step 2: accepts default gateway name', async () => { + await session.sendSpecialKey('enter'); + const found = await safeWaitFor(session, 'authorizer', 5_000); + if (!found) { + saveTextScreenshot(session, '02-authorizer-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '02-authorizer-type'); + }); + + it('Step 3: selects CUSTOM_JWT authorizer type', async () => { + // Authorizer list: 0: AWS IAM, 1: Custom JWT, 2: None + await session.sendSpecialKey('down'); + await settle(); + saveTextScreenshot(session, '03a-custom-jwt-highlighted'); + + await session.sendSpecialKey('enter'); + const found = await safeWaitFor(session, 'Configure Custom JWT Authorizer', 5_000); + if (!found) { + saveTextScreenshot(session, '03-jwt-config-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '03-jwt-discovery-url'); + }); + + it('Step 4: enters OIDC Discovery URL', async () => { + const url = 'https://login.example.com/.well-known/openid-configuration'; + await session.sendKeys(url); + await settle(); + saveTextScreenshot(session, '04a-discovery-url-typed'); + + await session.sendSpecialKey('enter'); + + // The constraint picker may use different text; try several markers + let found = await safeWaitFor(session, 'constraints', 8_000); + if (!found) { + const text = getScreenText(session); + found = text.includes('Allowed Audiences') || text.includes('toggle') || text.includes('Space'); + } + if (!found) { + saveTextScreenshot(session, '04-constraint-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '04-constraint-picker'); + }); + + it('Step 5: selects Audiences and Custom Claims constraints', async () => { + // Constraint list: + // 0: Allowed Audiences, 1: Allowed Clients, 2: Allowed Scopes, 3: Custom Claims + + // Toggle Allowed Audiences (cursor starts at 0) + await session.sendSpecialKey('space'); + await settle(300); + + // Move to Custom Claims (3 down) + await session.sendSpecialKey('down'); + await session.sendSpecialKey('down'); + await session.sendSpecialKey('down'); + await settle(300); + + // Toggle Custom Claims + await session.sendSpecialKey('space'); + await settle(300); + saveTextScreenshot(session, '05a-constraints-selected'); + + // Confirm selection + await session.sendSpecialKey('enter'); + + const found = await safeWaitFor(session, 'Audience', 5_000); + if (!found) { + saveTextScreenshot(session, '05-audience-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '05-audience-input'); + }); + + it('Step 6: enters audience values', async () => { + await session.sendKeys('aud-123, aud-456'); + await settle(); + saveTextScreenshot(session, '06a-audience-typed'); + + await session.sendSpecialKey('enter'); + + const found = await safeWaitFor(session, 'Custom Claims', 5_000); + if (!found) { + saveTextScreenshot(session, '06-claims-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '06-custom-claims-manager'); + }); + + it('Step 7: adds a custom claim via the tabbed form', async () => { + // CustomClaimsManager opens in 'add' mode when no claims exist yet. + // The form fields are: claimName, valueType, operator, matchValue + // Navigation: Tab cycles fields, left/right cycles select options, Enter saves. + + let found = await safeWaitFor(session, 'Claim name', 5_000); + if (!found) { + // Maybe we need to select "Add claim" from the action menu first + const text = getScreenText(session); + if (text.includes('Add claim')) { + await session.sendSpecialKey('enter'); + found = await safeWaitFor(session, 'Claim name', 3_000); + } + } + if (!found) { + saveTextScreenshot(session, '07-claim-form-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '07a-claim-form-empty'); + + // --- Fill in claim name --- + await session.sendKeys('department'); + await settle(300); + saveTextScreenshot(session, '07b-claim-name-typed'); + + // --- Tab to valueType, change to STRING_ARRAY --- + await session.sendSpecialKey('tab'); + await settle(300); + await session.sendSpecialKey('right'); // STRING -> STRING_ARRAY + await settle(300); + saveTextScreenshot(session, '07c-value-type-string-array'); + + // Verify the value type changed + let text = getScreenText(session); + expect(text).toContain('String Array'); + + // --- Tab to operator, change to CONTAINS_ANY --- + await session.sendSpecialKey('tab'); + await settle(300); + await session.sendSpecialKey('right'); // EQUALS -> CONTAINS + await settle(200); + await session.sendSpecialKey('right'); // CONTAINS -> CONTAINS_ANY + await settle(300); + saveTextScreenshot(session, '07d-operator-contains-any'); + + text = getScreenText(session); + expect(text).toContain('Contains Any'); + + // --- Tab to matchValue, type values --- + await session.sendSpecialKey('tab'); + await settle(300); + await session.sendKeys('engineering, sales'); + await settle(300); + saveTextScreenshot(session, '07e-match-value-typed'); + + // --- Press Enter to save the claim --- + await session.sendSpecialKey('enter'); + await settle(500); + + // Verify we returned to the claims list with our claim visible + text = getScreenText(session); + const hasClaim = text.includes('department') || text.includes('Add claim') || text.includes('Done'); + if (!hasClaim) { + saveTextScreenshot(session, '07f-claims-list-fail'); + } + expect(hasClaim).toBe(true); + saveTextScreenshot(session, '07f-claims-list-with-claim'); + }); + + it('Step 8: selects Done to finish claims configuration', async () => { + // After saving, we're in list mode with actions: + // 0: Add claim, 1: Edit existing claim, 2: Done + const text = getScreenText(session); + if (text.includes('Add claim')) { + await session.sendSpecialKey('down'); // -> Edit existing claim + await session.sendSpecialKey('down'); // -> Done + await settle(300); + saveTextScreenshot(session, '08a-done-highlighted'); + } + + await session.sendSpecialKey('enter'); + + // Should reach Client ID / OAuth step + let found = await safeWaitFor(session, 'Client ID', 5_000); + if (!found) { + const afterText = getScreenText(session); + found = + afterText.includes('OAuth') || + afterText.includes('credential') || + afterText.includes('skip') || + afterText.includes('Enter to skip'); + } + if (!found) { + saveTextScreenshot(session, '08-client-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '08-client-id-step'); + }); + + it('Step 9: skips OAuth client credentials', async () => { + // Press Enter with empty input to skip + await session.sendSpecialKey('enter'); + + // Should reach Advanced Config + let found = await safeWaitFor(session, 'Advanced', 5_000); + if (!found) { + const text = getScreenText(session); + found = + text.includes('Semantic') || text.includes('Toggle') || text.includes('toggle') || text.includes('Exception'); + } + if (!found) { + saveTextScreenshot(session, '09-advanced-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '09-advanced-config'); + }); + + it('Step 10: accepts advanced config defaults', async () => { + await session.sendSpecialKey('enter'); + + // Should reach Confirm / Review screen + let found = await safeWaitFor(session, 'Review', 5_000); + if (!found) { + const text = getScreenText(session); + found = text.includes('my-gateway') || text.includes('CUSTOM_JWT') || text.includes('Confirm'); + } + if (!found) { + saveTextScreenshot(session, '10-confirm-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, '10-confirm-review'); + }); + + it('Step 11: confirm review shows all JWT configuration details', () => { + const text = getScreenText(session); + + // Verify gateway name + expect(text).toContain('my-gateway'); + + // Verify authorizer type + expect(text).toContain('CUSTOM_JWT'); + + // Verify Discovery URL + expect(text).toContain('login.example.com'); + + // Verify audiences + expect(text).toContain('aud-123'); + + // Verify custom claims count + expect(text).toContain('1 claim'); + + // Save final screenshots + saveTextScreenshot(session, '11-confirm-review-final'); + + // Save SVG screenshot to the requested path + const svg = session.screenshot({ theme: DARK_THEME }); + const svgPath = '/tmp/tui-test-jwt/jwt-confirm-review.svg'; + writeFileSync(svgPath, svg, 'utf-8'); + }); + + it('Step 12: escape navigates back without creating', async () => { + await session.sendSpecialKey('escape'); + await settle(500); + + // Session should still be alive after pressing escape + expect(session.alive).toBe(true); + saveTextScreenshot(session, '12-after-escape'); + }); +}); diff --git a/integ-tests/tui/setup.ts b/integ-tests/tui/setup.ts new file mode 100644 index 000000000..088ec85fb --- /dev/null +++ b/integ-tests/tui/setup.ts @@ -0,0 +1,6 @@ +/** + * TUI integration test setup. + * + * This file is referenced by vitest.config.ts as a setupFile for the 'tui' project. + * It runs before each test file in integ-tests/tui/. + */ diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index d32824c12..89e897424 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -218,40 +218,29 @@ describe('validate', () => { expect(result.error?.includes('Invalid authorizer type')).toBeTruthy(); }); - // AC11: CUSTOM_JWT requires discoveryUrl - it('returns error for CUSTOM_JWT missing discoveryUrl', () => { - const opts = { ...validGatewayOptionsJwt, discoveryUrl: undefined }; - const result = validateAddGatewayOptions(opts); + // AC11: CUSTOM_JWT requires discoveryUrl; at least one of allowedAudience/allowedClients/allowedScopes + it('returns error for CUSTOM_JWT missing required fields', () => { + // discoveryUrl is always required + const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, discoveryUrl: undefined }); expect(result.valid).toBe(false); expect(result.error).toBe('--discovery-url is required for CUSTOM_JWT authorizer'); - }); - // AC11b: at least one of audience/clients/scopes required - it('returns error when all of audience, clients, and scopes are missing', () => { - const opts = { + // All three optional fields absent fails + const noneResult = validateAddGatewayOptions({ ...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); + }); + expect(noneResult.valid).toBe(false); + expect(noneResult.error).toBe( + 'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer' + ); }); - it('allows CUSTOM_JWT with only allowedAudience', () => { - const opts = { ...validGatewayOptionsJwt, allowedClients: undefined, allowedScopes: undefined }; + // AC11b: allowedAudience is optional + it('allows CUSTOM_JWT without allowedAudience', () => { + const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined }; const result = validateAddGatewayOptions(opts); expect(result.valid).toBe(true); }); @@ -269,21 +258,88 @@ describe('validate', () => { expect(result.error?.includes('.well-known/openid-configuration')).toBeTruthy(); }); - it('returns error for HTTP discoveryUrl (HTTPS required)', () => { + // AC13: At least one of audience/clients/scopes must be non-empty + it('returns error when all of audience, clients, and scopes are empty', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, - discoveryUrl: 'http://example.com/.well-known/openid-configuration', + allowedAudience: ' ', + allowedClients: undefined, + allowedScopes: undefined, }); expect(result.valid).toBe(false); - expect(result.error).toBe('Discovery URL must use HTTPS'); + expect(result.error).toBe( + 'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer' + ); }); - it('allows CUSTOM_JWT with only allowedClients', () => { - const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined, allowedScopes: undefined }; - const result = validateAddGatewayOptions(opts); + // AC-claims1: --custom-claims with valid JSON passes validation + it('accepts valid --custom-claims JSON', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + customClaims: JSON.stringify([ + { + inboundTokenClaimName: 'dept', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + }, + ]), + }); expect(result.valid).toBe(true); }); + // AC-claims2: --custom-claims alone satisfies the "at least one constraint" check + it('allows CUSTOM_JWT with only --custom-claims (no audience/clients/scopes)', () => { + const result = validateAddGatewayOptions({ + name: 'test-gw', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + customClaims: JSON.stringify([ + { + inboundTokenClaimName: 'role', + inboundTokenClaimValueType: 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY', + claimMatchValue: { matchValueStringList: ['admin'] }, + }, + }, + ]), + }); + expect(result.valid).toBe(true); + }); + + // AC-claims3: --custom-claims with invalid JSON fails + it('returns error for --custom-claims with invalid JSON', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + customClaims: 'not json', + }); + expect(result.valid).toBe(false); + expect(result.error).toBe('--custom-claims must be valid JSON'); + }); + + // AC-claims4: --custom-claims with empty array fails + it('returns error for --custom-claims with empty array', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + customClaims: '[]', + }); + expect(result.valid).toBe(false); + expect(result.error).toBe('--custom-claims must be a non-empty JSON array'); + }); + + // AC-claims5: --custom-claims with invalid claim structure fails + it('returns error for --custom-claims with invalid claim structure', () => { + const result = validateAddGatewayOptions({ + ...validGatewayOptionsJwt, + customClaims: JSON.stringify([{ badField: 'value' }]), + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid custom claim at index 0'); + }); + // AC14: Valid options pass it('passes for valid options', () => { expect(validateAddGatewayOptions(validGatewayOptionsNone)).toEqual({ valid: true }); @@ -309,8 +365,8 @@ describe('validate', () => { expect(result.error).toBe('Both --client-id and --client-secret must be provided together'); }); - // AC16: OAuth credentials only valid with CUSTOM_JWT - it('returns error when OAuth credentials used with non-CUSTOM_JWT authorizer', () => { + // AC16: OAuth client credentials only valid with CUSTOM_JWT + it('returns error when OAuth client credentials used with non-CUSTOM_JWT authorizer', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsNone, clientId: 'my-client-id', @@ -320,8 +376,8 @@ describe('validate', () => { expect(result.error).toBe('OAuth client credentials are only valid with CUSTOM_JWT authorizer'); }); - // AC17: valid CUSTOM_JWT with OAuth credentials passes - it('passes for CUSTOM_JWT with OAuth credentials', () => { + // AC17: valid CUSTOM_JWT with OAuth client credentials passes + it('passes for CUSTOM_JWT with OAuth client credentials', () => { const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, clientId: 'my-client-id', diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 5561dc6d4..78b247f7f 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -37,6 +37,7 @@ export interface AddGatewayOptions { allowedAudience?: string; allowedClients?: string; allowedScopes?: string; + customClaims?: string; clientId?: string; clientSecret?: string; agents?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index f7b1f92da..07d6b1398 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -2,6 +2,7 @@ import { ConfigIO, findConfigRoot } from '../../../lib'; import { AgentNameSchema, BuildTypeSchema, + CustomClaimValidationSchema, GatewayExceptionLevelSchema, GatewayNameSchema, ModelProviderSchema, @@ -275,16 +276,36 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio return { valid: false, error: `Discovery URL must end with ${OIDC_WELL_KNOWN_SUFFIX}` }; } - // allowedAudience, allowedClients, allowedScopes are all optional individually, + // Validate custom claims JSON if provided + if (options.customClaims) { + let parsed: unknown; + try { + parsed = JSON.parse(options.customClaims); + } catch { + return { valid: false, error: '--custom-claims must be valid JSON' }; + } + if (!Array.isArray(parsed) || parsed.length === 0) { + return { valid: false, error: '--custom-claims must be a non-empty JSON array' }; + } + for (const [i, entry] of parsed.entries()) { + const result = CustomClaimValidationSchema.safeParse(entry); + if (!result.success) { + return { valid: false, error: `Invalid custom claim at index ${i}: ${result.error.issues[0]?.message}` }; + } + } + } + + // allowedAudience, allowedClients, allowedScopes, customClaims 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) { + const hasClaims = !!options.customClaims?.trim(); + if (!hasAudience && !hasClients && !hasScopes && !hasClaims) { return { valid: false, error: - 'At least one of --allowed-audience, --allowed-clients, or --allowed-scopes must be provided for CUSTOM_JWT authorizer', + 'At least one of --allowed-audience, --allowed-clients, --allowed-scopes, or --custom-claims must be provided for CUSTOM_JWT authorizer', }; } } diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 9d4d41a70..4faa64014 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -4,6 +4,7 @@ import type { AgentCoreGatewayTarget, AgentCoreMcpSpec, AgentCoreProjectSpec, + CustomClaimValidation, GatewayAuthorizerType, } from '../../schema'; import { AgentCoreGatewaySchema, PolicyEngineModeSchema } from '../../schema'; @@ -29,6 +30,7 @@ export interface AddGatewayOptions { allowedAudience?: string; allowedClients?: string; allowedScopes?: string; + customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; agents?: string; @@ -164,6 +166,7 @@ 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('--custom-claims ', 'Custom claim validations as JSON array (for CUSTOM_JWT)') .option('--client-id ', 'OAuth client ID for gateway bearer token') .option('--client-secret ', 'OAuth client secret') .option('--agents ', 'Comma-separated agent names') @@ -190,6 +193,11 @@ export class GatewayPrimitive extends BasePrimitive { primitive = new GatewayPrimitive(); }); + describe('customClaims pipeline', () => { + const SAMPLE_CLAIMS = [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING_ARRAY' as const, + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY' as const, + claimMatchValue: { matchValueStringList: ['engineering', 'sales'] }, + }, + }, + ]; + + it('custom claims from TUI flow are written to authorizerConfiguration', async () => { + await primitive.add({ + name: 'jwt-gw', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: 'aud1', + customClaims: SAMPLE_CLAIMS, + }); + + const gw = getWrittenGateway(); + expect(gw.authorizerConfiguration?.customJwtAuthorizer).toBeDefined(); + expect(gw.authorizerConfiguration!.customJwtAuthorizer!.customClaims).toEqual(SAMPLE_CLAIMS); + }); + + it('custom claims are preserved alongside audience and clients', async () => { + await primitive.add({ + name: 'jwt-gw', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: 'aud1,aud2', + allowedClients: 'client1', + customClaims: SAMPLE_CLAIMS, + }); + + const gw = getWrittenGateway(); + const jwtConfig = gw.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwtConfig.allowedAudience).toEqual(['aud1', 'aud2']); + expect(jwtConfig.allowedClients).toEqual(['client1']); + expect(jwtConfig.customClaims).toEqual(SAMPLE_CLAIMS); + }); + + it('omits customClaims from authorizerConfiguration when not provided', async () => { + await primitive.add({ + name: 'jwt-gw', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: 'aud1', + }); + + const gw = getWrittenGateway(); + expect(gw.authorizerConfiguration!.customJwtAuthorizer!.customClaims).toBeUndefined(); + }); + + it('custom claims only (no audience/clients/scopes) produces valid config', async () => { + await primitive.add({ + name: 'jwt-gw', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + customClaims: SAMPLE_CLAIMS, + }); + + const gw = getWrittenGateway(); + const jwtConfig = gw.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwtConfig.allowedAudience).toBeUndefined(); + expect(jwtConfig.allowedClients).toBeUndefined(); + expect(jwtConfig.allowedScopes).toBeUndefined(); + expect(jwtConfig.customClaims).toEqual(SAMPLE_CLAIMS); + }); + }); + describe('exceptionLevel', () => { it('defaults to exceptionLevel NONE', async () => { await primitive.add({ name: 'test-gw', authorizerType: 'NONE' }); diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index b3d999d7b..2b3b3b25a 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -31,6 +31,7 @@ export function useCreateGateway() { allowedAudience: config.jwtConfig?.allowedAudience?.join(','), allowedClients: config.jwtConfig?.allowedClients?.join(','), allowedScopes: config.jwtConfig?.allowedScopes?.join(','), + customClaims: config.jwtConfig?.customClaims, clientId: config.jwtConfig?.clientId, clientSecret: config.jwtConfig?.clientSecret, enableSemanticSearch: config.enableSemanticSearch, diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 362a8eb38..21a204b4a 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -3,6 +3,7 @@ import { GatewayNameSchema } from '../../../../schema'; import { computeManagedOAuthCredentialName } from '../../../primitives/credential-utils'; import { ConfirmReview, + Cursor, Panel, Screen, SecretInput, @@ -25,8 +26,8 @@ import { SEMANTIC_SEARCH_ITEM_ID, } from './types'; import { useAddGatewayWizard } from './useAddGatewayWizard'; -import { Box, Text } from 'ink'; -import React, { useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useMemo, useState } from 'react'; interface AddGatewayScreenProps { onComplete: (config: AddGatewayConfig) => void; @@ -47,13 +48,16 @@ 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=clientId, 5=clientSecret) - const [jwtSubStep, setJwtSubStep] = useState(0); + // JWT config state + const [jwtSubStep, setJwtSubStep] = useState('discoveryUrl'); const [jwtDiscoveryUrl, setJwtDiscoveryUrl] = useState(''); + const [jwtSelectedConstraints, setJwtSelectedConstraints] = useState>(new Set()); const [jwtAudience, setJwtAudience] = useState(''); const [jwtClients, setJwtClients] = useState(''); const [jwtScopes, setJwtScopes] = useState(''); + const [jwtCustomClaims, setJwtCustomClaims] = useState([]); const [jwtClientId, setJwtClientId] = useState(''); + const [claimsManagerMode, setClaimsManagerMode] = useState('add'); const unassignedTargetItems: SelectableItem[] = useMemo( () => unassignedTargets.map(name => ({ id: name, title: name })), @@ -172,67 +176,141 @@ export function AddGatewayScreen({ isActive: isConfirmStep, }); + // Compute the ordered list of JWT sub-steps based on selected constraints + const jwtSteps = useMemo(() => { + const steps: JwtSubStep[] = ['discoveryUrl', 'constraintPicker']; + if (jwtSelectedConstraints.has('audience')) steps.push('audience'); + if (jwtSelectedConstraints.has('clients')) steps.push('clients'); + if (jwtSelectedConstraints.has('scopes')) steps.push('scopes'); + if (jwtSelectedConstraints.has('customClaims')) steps.push('customClaims'); + steps.push('clientId', 'clientSecret'); + return steps; + }, [jwtSelectedConstraints]); + + const jwtStepIndex = jwtSteps.indexOf(jwtSubStep); + + // Navigate to the next JWT sub-step after current + const jwtGoNext = useCallback(() => { + const nextStep = jwtSteps[jwtStepIndex + 1]; + if (nextStep) setJwtSubStep(nextStep); + }, [jwtSteps, jwtStepIndex]); + + // Navigate to the previous JWT sub-step + const jwtGoBack = useCallback(() => { + if (jwtStepIndex <= 0) { + wizard.goBack(); + } else { + const prevStep = jwtSteps[jwtStepIndex - 1]; + if (prevStep) setJwtSubStep(prevStep); + } + }, [jwtSteps, jwtStepIndex, wizard]); + // JWT config handlers const handleJwtDiscoveryUrl = (url: string) => { setJwtDiscoveryUrl(url); - setJwtSubStep(1); + setJwtSubStep('constraintPicker'); }; + const handleJwtConstraintsPicked = useCallback((selectedIds: string[]) => { + const constraints = new Set(selectedIds as ConstraintType[]); + setJwtSelectedConstraints(constraints); + // Find first selected constraint in order + const order: ConstraintType[] = ['audience', 'clients', 'scopes', 'customClaims']; + const first = order.find(c => constraints.has(c)); + if (first) { + setJwtSubStep(first); + } else { + setJwtSubStep('clientId'); + } + }, []); + const handleJwtAudience = (audience: string) => { setJwtAudience(audience); - setJwtSubStep(2); + jwtGoNext(); }; const handleJwtClients = (clients: string) => { setJwtClients(clients); - setJwtSubStep(3); + jwtGoNext(); }; const handleJwtScopes = (scopes: string) => { setJwtScopes(scopes); - setJwtSubStep(4); + jwtGoNext(); }; + const handleJwtCustomClaimsDone = useCallback( + (claims: CustomClaimEntry[]) => { + setJwtCustomClaims(claims); + jwtGoNext(); + }, + [jwtGoNext] + ); + const handleJwtClientId = (clientId: string) => { setJwtClientId(clientId); - setJwtSubStep(5); + jwtGoNext(); }; - const handleJwtClientSecret = (clientSecret: string) => { - const audienceList = jwtAudience - .split(',') - .map(s => s.trim()) - .filter(Boolean); - const clientsList = jwtClients - .split(',') - .map(s => s.trim()) - .filter(Boolean); - const scopesList = jwtScopes - .split(',') - .map(s => s.trim()) - .filter(Boolean); + const handleJwtClientIdSkip = () => { + setJwtClientId(''); + finishJwtConfig(''); + }; + + const finishJwtConfig = (clientSecret: string) => { + const parseList = (s: string) => + s + .split(',') + .map(v => v.trim()) + .filter(Boolean); + const audienceList = jwtSelectedConstraints.has('audience') ? parseList(jwtAudience) : undefined; + const clientsList = jwtSelectedConstraints.has('clients') ? parseList(jwtClients) : undefined; + const scopesList = jwtSelectedConstraints.has('scopes') ? parseList(jwtScopes) : undefined; wizard.setJwtConfig({ discoveryUrl: jwtDiscoveryUrl, - ...(audienceList.length > 0 ? { allowedAudience: audienceList } : {}), - ...(clientsList.length > 0 ? { allowedClients: clientsList } : {}), - ...(scopesList.length > 0 ? { allowedScopes: scopesList } : {}), - ...(jwtClientId ? { clientId: jwtClientId, clientSecret } : {}), + ...(audienceList && audienceList.length > 0 ? { allowedAudience: audienceList } : {}), + ...(clientsList && clientsList.length > 0 ? { allowedClients: clientsList } : {}), + ...(scopesList && scopesList.length > 0 ? { allowedScopes: scopesList } : {}), + ...(jwtSelectedConstraints.has('customClaims') && jwtCustomClaims.length > 0 + ? { + customClaims: jwtCustomClaims.map(c => ({ + inboundTokenClaimName: c.claimName, + inboundTokenClaimValueType: c.valueType, + authorizingClaimMatchValue: { + claimMatchOperator: c.operator, + claimMatchValue: + c.valueType === 'STRING' + ? { matchValueString: c.matchValue } + : { + matchValueStringList: c.matchValue + .split(',') + .map(v => v.trim()) + .filter(Boolean), + }, + }, + })), + } + : {}), + ...(jwtClientId.trim() ? { clientId: jwtClientId, clientSecret } : {}), }); - setJwtSubStep(0); + setJwtSubStep('discoveryUrl'); }; - const handleJwtCancel = () => { - if (jwtSubStep === 0) { - wizard.goBack(); - } else { - setJwtSubStep(jwtSubStep - 1); - } + const handleJwtClientSecret = (clientSecret: string) => { + finishJwtConfig(clientSecret); }; - const helpText = - isIncludeTargetsStep || isAdvancedConfigStep + const helpText = isJwtConfigStep + ? jwtSubStep === 'constraintPicker' + ? HELP_TEXT.MULTI_SELECT + : jwtSubStep === 'customClaims' + ? claimsManagerMode === 'add' || claimsManagerMode === 'edit' + ? 'Tab field · ←/→ cycle · Enter save · Esc cancel' + : 'Navigate · Enter select · Esc back' + : HELP_TEXT.TEXT_INPUT + : isIncludeTargetsStep || isAdvancedConfigStep ? 'Space toggle · Enter confirm · Esc back' : isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL @@ -276,13 +354,24 @@ export function AddGatewayScreen({ {isJwtConfigStep && ( )} @@ -363,6 +452,14 @@ export function AddGatewayScreen({ ...(wizard.config.jwtConfig.allowedScopes?.length ? [{ label: 'Allowed Scopes', value: wizard.config.jwtConfig.allowedScopes.join(', ') }] : []), + ...(wizard.config.jwtConfig.customClaims?.length + ? [ + { + label: 'Custom Claims', + value: `${wizard.config.jwtConfig.customClaims.length} claim(s) configured`, + }, + ] + : []), ...(wizard.config.jwtConfig.clientId ? [{ label: 'Gateway Credential', value: computeManagedOAuthCredentialName(wizard.config.name) }] : []), @@ -391,54 +488,138 @@ export function AddGatewayScreen({ ); } +// ───────────────────────────────────────────────────────────────────────────── +// JWT Configuration Types & Constants +// ───────────────────────────────────────────────────────────────────────────── + +type ConstraintType = 'audience' | 'clients' | 'scopes' | 'customClaims'; + +type JwtSubStep = + | 'discoveryUrl' + | 'constraintPicker' + | 'audience' + | 'clients' + | 'scopes' + | 'customClaims' + | 'clientId' + | 'clientSecret'; + +type ClaimValueType = 'STRING' | 'STRING_ARRAY'; +type ClaimOperator = 'EQUALS' | 'CONTAINS' | 'CONTAINS_ANY'; + +interface CustomClaimEntry { + claimName: string; + valueType: ClaimValueType; + operator: ClaimOperator; + matchValue: string; +} + +const CONSTRAINT_ITEMS: SelectableItem[] = [ + { id: 'audience', title: 'Allowed Audiences', description: 'Validate token audience claims' }, + { id: 'clients', title: 'Allowed Clients', description: 'Validate client identifiers in the token' }, + { id: 'scopes', title: 'Allowed Scopes', description: 'Validate token contains required scopes' }, + { id: 'customClaims', title: 'Custom Claims', description: 'Match specific token claims against rules' }, +]; + +/** OIDC well-known suffix for validation */ +const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; + +/** Validates that a comma-separated string has at least one non-empty value */ +function validateCommaSeparated(value: string): true | string { + const items = value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + return items.length > 0 || 'At least one value is required'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// JwtConfigInput — main JWT configuration component +// ───────────────────────────────────────────────────────────────────────────── + interface JwtConfigInputProps { - subStep: number; + subStep: JwtSubStep; + steps: JwtSubStep[]; + selectedConstraints: Set; + customClaims: CustomClaimEntry[]; + discoveryUrl: string; + audience: string; + clients: string; + scopes: string; onDiscoveryUrl: (url: string) => void; + onConstraintsPicked: (selectedIds: string[]) => void; onAudience: (audience: string) => void; onClients: (clients: string) => void; onScopes: (scopes: string) => void; + onCustomClaimsDone: (claims: CustomClaimEntry[]) => void; onClientId: (clientId: string) => void; + onClientIdSkip: () => void; onClientSecret: (clientSecret: string) => void; - onCancel: () => void; + onBack: () => void; + onClaimsManagerModeChange?: (mode: ClaimsManagerMode) => void; } - -/** OIDC well-known suffix for validation */ -const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; - function JwtConfigInput({ subStep, + steps, + selectedConstraints, + customClaims, + discoveryUrl, + audience, + clients, + scopes, onDiscoveryUrl, + onConstraintsPicked, onAudience, onClients, onScopes, + onCustomClaimsDone, onClientId, + onClientIdSkip, onClientSecret, - onCancel, + onBack, + onClaimsManagerModeChange, }: JwtConfigInputProps) { - const totalSteps = 6; + // Count only the user-facing steps (exclude clientId/clientSecret which are optional) + const coreSteps = steps.filter(s => s !== ('clientId' as JwtSubStep) && s !== ('clientSecret' as JwtSubStep)); + const coreIndex = coreSteps.indexOf(subStep); + const displayStep = coreIndex >= 0 ? coreIndex + 1 : coreSteps.length; + const totalDisplay = coreSteps.length; + + const constraintNav = useMultiSelectNavigation({ + items: CONSTRAINT_ITEMS, + getId: item => item.id, + initialSelectedIds: Array.from(selectedConstraints), + onConfirm: onConstraintsPicked, + onExit: () => onBack(), + isActive: subStep === 'constraintPicker', + requireSelection: true, + }); + return ( Configure Custom JWT Authorizer - - Step {subStep + 1} of {totalSteps} - - - {subStep === 0 && ( + {subStep !== 'clientId' && subStep !== 'clientSecret' && ( + + Step {displayStep} of {totalDisplay} + + )} + + {subStep === 'discoveryUrl' && ( { - let parsed: URL; try { - parsed = new URL(value); + const url = new URL(value); + if (url.protocol !== 'https:') { + return 'Discovery URL must use HTTPS'; + } } 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}'`; } @@ -446,49 +627,76 @@ function JwtConfigInput({ }} /> )} - {subStep === 1 && ( + {subStep === 'constraintPicker' && ( + + + + )} + {subStep === 'audience' && ( )} - {subStep === 2 && ( + {subStep === 'clients' && ( )} - {subStep === 3 && ( + {subStep === 'scopes' && ( )} - {subStep === 4 && ( - )} - {subStep === 5 && ( + {subStep === 'clientId' && ( + + Optional: Provide OAuth credentials for gateway bearer token fetching + + { + if (value.trim()) onClientId(value); + else onClientIdSkip(); + }} + onCancel={onBack} + allowEmpty + /> + + + )} + {subStep === 'clientSecret' && ( value.trim().length > 0 || 'Client secret is required'} revealChars={4} /> @@ -497,3 +705,350 @@ function JwtConfigInput({ ); } + +// ───────────────────────────────────────────────────────────────────────────── +// CustomClaimsManager — add/edit/done loop for custom claims +// ───────────────────────────────────────────────────────────────────────────── + +interface CustomClaimsManagerProps { + initialClaims: CustomClaimEntry[]; + onDone: (claims: CustomClaimEntry[]) => void; + onCancel: () => void; + onModeChange?: (mode: ClaimsManagerMode) => void; +} + +type ClaimsManagerMode = 'list' | 'add' | 'edit-pick' | 'edit' | 'delete-pick'; + +function CustomClaimsManager({ initialClaims, onDone, onCancel, onModeChange }: CustomClaimsManagerProps) { + const [claims, setClaims] = useState(initialClaims); + const [mode, setMode] = useState(initialClaims.length > 0 ? 'list' : 'add'); + const [editIndex, setEditIndex] = useState(-1); + + React.useEffect(() => { + onModeChange?.(mode); + }, [mode, onModeChange]); + + // Action items for the list view + const actionItems = useMemo(() => { + const items: SelectableItem[] = [{ id: 'add', title: 'Add claim' }]; + if (claims.length > 0) { + items.push({ id: 'edit', title: 'Edit existing claim' }); + items.push({ id: 'delete', title: 'Delete claim' }); + items.push({ id: 'done', title: 'Done' }); + } + return items; + }, [claims.length]); + + const actionNav = useListNavigation({ + items: actionItems, + onSelect: item => { + if (item.id === 'add') setMode('add'); + else if (item.id === 'edit') setMode('edit-pick'); + else if (item.id === 'delete') setMode('delete-pick'); + else if (item.id === 'done') onDone(claims); + }, + onExit: onCancel, + isActive: mode === 'list', + }); + + // Claim picker for edit mode + const claimPickerItems = useMemo( + () => claims.map((c, i) => ({ id: String(i), title: formatClaimSummary(c) })), + [claims] + ); + + const claimPickerNav = useListNavigation({ + items: claimPickerItems, + onSelect: (_, index) => { + setEditIndex(index); + setMode('edit'); + }, + onExit: () => setMode('list'), + isActive: mode === 'edit-pick', + }); + + const deletePickerNav = useListNavigation({ + items: claimPickerItems, + onSelect: (_, index) => { + setClaims(prev => { + const next = prev.filter((_, i) => i !== index); + setMode(next.length === 0 ? 'add' : 'list'); + return next; + }); + }, + onExit: () => setMode('list'), + isActive: mode === 'delete-pick', + }); + + const handleClaimSave = useCallback( + (claim: CustomClaimEntry) => { + if (mode === 'edit' && editIndex >= 0) { + setClaims(prev => prev.map((c, i) => (i === editIndex ? claim : c))); + } else { + setClaims(prev => [...prev, claim]); + } + setMode('list'); + setEditIndex(-1); + }, + [mode, editIndex] + ); + + const handleClaimCancel = useCallback(() => { + if (claims.length > 0) { + setMode('list'); + } else { + onCancel(); + } + }, [claims.length, onCancel]); + + return ( + + Custom Claims + + {mode === 'list' && ( + + {claims.length > 0 && ( + + {claims.map((claim, i) => ( + + {i + 1}. {formatClaimSummary(claim)} + + ))} + + )} + + {actionItems.map((item, idx) => { + const isCursor = idx === actionNav.selectedIndex; + return ( + + + {isCursor ? '❯' : ' '} {item.title} + + + ); + })} + + + )} + + {mode === 'edit-pick' && ( + + Select a claim to edit: + + {claimPickerItems.map((item, idx) => { + const isCursor = idx === claimPickerNav.selectedIndex; + return ( + + + {isCursor ? '❯' : ' '} {item.title} + + + ); + })} + + + )} + + {mode === 'delete-pick' && ( + + Select a claim to delete: + + {claimPickerItems.map((item, idx) => { + const isCursor = idx === deletePickerNav.selectedIndex; + return ( + + + {isCursor ? '❯' : ' '} {item.title} + + + ); + })} + + + )} + + {(mode === 'add' || mode === 'edit') && ( + = 0 ? claims[editIndex] : undefined} + onSave={handleClaimSave} + onCancel={handleClaimCancel} + /> + )} + + ); +} + +function formatClaimSummary(claim: CustomClaimEntry): string { + const opLabel = claim.operator === 'EQUALS' ? '=' : claim.operator === 'CONTAINS' ? 'contains' : 'contains any of'; + const valueDisplay = claim.valueType === 'STRING_ARRAY' ? `[${claim.matchValue}]` : `"${claim.matchValue}"`; + return `${claim.claimName} ${opLabel} ${valueDisplay}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CustomClaimForm — tab-field form for a single custom claim +// ───────────────────────────────────────────────────────────────────────────── + +const VALUE_TYPES: ClaimValueType[] = ['STRING', 'STRING_ARRAY']; +const OPERATORS: ClaimOperator[] = ['EQUALS', 'CONTAINS', 'CONTAINS_ANY']; + +type ClaimField = 'claimName' | 'valueType' | 'operator' | 'matchValue'; +const CLAIM_FIELDS: ClaimField[] = ['claimName', 'valueType', 'operator', 'matchValue']; + +interface CustomClaimFormProps { + initialClaim?: CustomClaimEntry; + onSave: (claim: CustomClaimEntry) => void; + onCancel: () => void; +} + +function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProps) { + const [activeField, setActiveField] = useState('claimName'); + const [claimName, setClaimName] = useState(initialClaim?.claimName ?? ''); + const [valueType, setValueType] = useState(initialClaim?.valueType ?? 'STRING'); + const [operator, setOperator] = useState(initialClaim?.operator ?? 'EQUALS'); + const [matchValue, setMatchValue] = useState(initialClaim?.matchValue ?? ''); + const [error, setError] = useState(null); + + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + + // Tab / Shift+Tab to cycle fields + if (key.tab) { + const idx = CLAIM_FIELDS.indexOf(activeField); + if (key.shift) { + setActiveField(CLAIM_FIELDS[(idx - 1 + CLAIM_FIELDS.length) % CLAIM_FIELDS.length]!); + } else { + setActiveField(CLAIM_FIELDS[(idx + 1) % CLAIM_FIELDS.length]!); + } + setError(null); + return; + } + + // Enter to submit + if (key.return) { + if (!claimName.trim()) { + setError('Claim name is required'); + return; + } + if (!/^[A-Za-z0-9_.\-:]+$/.test(claimName.trim())) { + setError('Claim name may only contain letters, digits, _, ., -, :'); + return; + } + if (!matchValue.trim()) { + setError('Match value is required'); + return; + } + if (valueType === 'STRING_ARRAY') { + const values = matchValue + .split(',') + .map(v => v.trim()) + .filter(Boolean); + if (values.length === 0) { + setError('At least one non-empty value is required'); + return; + } + } + onSave({ claimName: claimName.trim(), valueType, operator, matchValue: matchValue.trim() }); + return; + } + + // For text fields: handle typing + if (activeField === 'claimName' || activeField === 'matchValue') { + if (key.backspace || key.delete) { + if (activeField === 'claimName') setClaimName(v => v.slice(0, -1)); + else setMatchValue(v => v.slice(0, -1)); + setError(null); + return; + } + if (input && !key.ctrl && !key.meta) { + if (activeField === 'claimName') setClaimName(v => v + input); + else setMatchValue(v => v + input); + setError(null); + return; + } + } + + // For select fields: left/right to cycle + if (activeField === 'valueType') { + if (key.leftArrow || key.rightArrow) { + const idx = VALUE_TYPES.indexOf(valueType); + const next = key.rightArrow + ? (idx + 1) % VALUE_TYPES.length + : (idx - 1 + VALUE_TYPES.length) % VALUE_TYPES.length; + setValueType(VALUE_TYPES[next]!); + return; + } + } + + if (activeField === 'operator') { + if (key.leftArrow || key.rightArrow) { + const idx = OPERATORS.indexOf(operator); + const next = key.rightArrow ? (idx + 1) % OPERATORS.length : (idx - 1 + OPERATORS.length) % OPERATORS.length; + setOperator(OPERATORS[next]!); + return; + } + } + }); + + return ( + + {initialClaim ? 'Edit Claim' : 'New Claim'} + + + + Claim name: + + {claimName || e.g., department} + + {activeField === 'claimName' && } + + + + Value type: + + {valueType === 'STRING' ? 'String' : 'String Array'} + + {activeField === 'valueType' && ( + + {' '} + ◂ {VALUE_TYPES.indexOf(valueType) + 1}/{VALUE_TYPES.length} ▸ + + )} + + + + Operator: + + {operator === 'EQUALS' ? 'Equals' : operator === 'CONTAINS' ? 'Contains' : 'Contains Any'} + + {activeField === 'operator' && ( + + {' '} + ◂ {OPERATORS.indexOf(operator) + 1}/{OPERATORS.length} ▸ + + )} + + + + Match value: + + {matchValue || ( + + {valueType === 'STRING_ARRAY' ? 'comma-separated, e.g., admin, dev' : 'e.g., engineering'} + + )} + + {activeField === 'matchValue' && } + + + + {error && ( + + {error} + + )} + + ); +} diff --git a/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx new file mode 100644 index 000000000..2d7d3ad72 --- /dev/null +++ b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx @@ -0,0 +1,652 @@ +import { AddGatewayScreen } from '../AddGatewayScreen.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const DOWN_ARROW = '\x1B[B'; +const ENTER = '\r'; +const ESCAPE = '\x1B'; +const SPACE = ' '; +const TAB = '\t'; +const LEFT_ARROW = '\x1B[D'; +const RIGHT_ARROW = '\x1B[C'; +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +const DEFAULT_PROPS = { + onComplete: vi.fn(), + onExit: vi.fn(), + existingGateways: [], + unassignedTargets: [], +}; + +// Helper: navigate past the name step by pressing Enter to accept the generated default name +async function acceptName(stdin: ReturnType['stdin']) { + await delay(); + stdin.write(ENTER); + await delay(); +} + +// Helper: navigate past the name step then select CUSTOM_JWT (index 1) and confirm +async function navigateToJwtConfig(stdin: ReturnType['stdin']) { + await acceptName(stdin); + // Authorizer step: down to CUSTOM_JWT (index 1), then Enter + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(ENTER); + await delay(); +} + +// Helper: fill discovery URL sub-step with a valid OIDC URL +async function enterDiscoveryUrl( + stdin: ReturnType['stdin'], + url = 'https://example.com/.well-known/openid-configuration' +) { + for (const ch of url) { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); +} + +// Helper: select at least one constraint (audience at index 0) and confirm +async function selectAudienceConstraint(stdin: ReturnType['stdin']) { + // Cursor is already on the first item (audience). Toggle it with SPACE. + stdin.write(SPACE); + await delay(); + stdin.write(ENTER); + await delay(); +} + +afterEach(() => vi.restoreAllMocks()); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 1: JWT Flow Navigation +// ───────────────────────────────────────────────────────────────────────────── + +describe('JWT Flow Navigation', () => { + it('shows "Configure Custom JWT Authorizer" after selecting CUSTOM_JWT', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + + expect(lastFrame()).toContain('Configure Custom JWT Authorizer'); + }); + + it('shows Discovery URL prompt on first JWT sub-step', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + + expect(lastFrame()).toContain('Discovery URL'); + }); + + it('shows error for invalid discovery URL (not a URL)', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + + // Type an invalid URL + for (const ch of 'not-a-url') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('Must be a valid URL'); + }); + + it('shows error for URL missing the OIDC well-known suffix', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + + for (const ch of 'https://example.com/auth') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('/.well-known/openid-configuration'); + }); + + it('proceeds to constraint picker after entering valid discovery URL', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + expect(lastFrame()).toContain('Select JWT constraints'); + }); + + it('Esc from discovery URL goes back to authorizer step', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + // Now on discoveryUrl sub-step — press Escape + stdin.write(ESCAPE); + await delay(); + + // Should be back on the authorizer step + expect(lastFrame()).toContain('Select authorizer type'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 2: Constraint Picker +// ───────────────────────────────────────────────────────────────────────────── + +describe('Constraint Picker', () => { + it('renders all 4 constraint items', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + const frame = lastFrame()!; + expect(frame).toContain('Allowed Audiences'); + expect(frame).toContain('Allowed Clients'); + expect(frame).toContain('Allowed Scopes'); + expect(frame).toContain('Custom Claims'); + }); + + it('selecting audience only proceeds to audience input', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + await selectAudienceConstraint(stdin); + + expect(lastFrame()).toContain('Allowed Audiences'); + }); + + it('selecting multiple constraints flows through them in order', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + // Toggle audience (index 0) and clients (index 1) + stdin.write(SPACE); // toggle audience + await delay(); + stdin.write(DOWN_ARROW); // move to clients + await delay(); + stdin.write(SPACE); // toggle clients + await delay(); + stdin.write(ENTER); + await delay(); + + // First should be audience + expect(lastFrame()).toContain('Allowed Audiences'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 3: Constraint Inputs +// ───────────────────────────────────────────────────────────────────────────── + +describe('Constraint Inputs', () => { + it('audience input accepts text and submits', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + await selectAudienceConstraint(stdin); // now on audience sub-step + + for (const ch of 'aud-123, aud-456') { + stdin.write(ch); + } + await delay(); + + expect(lastFrame()).toContain('aud-123'); + + stdin.write(ENTER); + await delay(); + + // After submitting audience, should have moved past (to clientId since audience is the only constraint) + // clientId shows the optional OAuth credentials prompt + expect(lastFrame()).toContain('OAuth Client ID'); + }); + + it('clients input accepts text and submits', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + // Select only clients (index 1) + stdin.write(DOWN_ARROW); // move to clients + await delay(); + stdin.write(SPACE); // toggle clients + await delay(); + stdin.write(ENTER); + await delay(); + + // Should be on clients sub-step + expect(lastFrame()).toContain('Allowed Clients'); + + for (const ch of 'client-abc') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + // Moved to clientId + expect(lastFrame()).toContain('OAuth Client ID'); + }); + + it('scopes input accepts text and submits', async () => { + const { lastFrame, stdin } = render(); + + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + // Select only scopes (index 2) + stdin.write(DOWN_ARROW); // move to clients + await delay(); + stdin.write(DOWN_ARROW); // move to scopes + await delay(); + stdin.write(SPACE); // toggle scopes + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('Allowed Scopes'); + + for (const ch of 'openid profile') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('OAuth Client ID'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 4: Custom Claims Form +// ───────────────────────────────────────────────────────────────────────────── + +describe('Custom Claims Form', () => { + // Helper: navigate all the way to the CustomClaimForm (add mode, started with no claims) + async function navigateToClaimForm(stdin: ReturnType['stdin']) { + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + // Select only customClaims (index 3) + stdin.write(DOWN_ARROW); // move to clients + await delay(); + stdin.write(DOWN_ARROW); // move to scopes + await delay(); + stdin.write(DOWN_ARROW); // move to customClaims + await delay(); + stdin.write(SPACE); // toggle customClaims + await delay(); + stdin.write(ENTER); + await delay(); + // Now in CustomClaimsManager in 'add' mode (no initial claims) + } + + it('form shows all 4 fields', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + const frame = lastFrame()!; + expect(frame).toContain('Claim name'); + expect(frame).toContain('Value type'); + expect(frame).toContain('Operator'); + expect(frame).toContain('Match value'); + }); + + it('Tab cycles through fields', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Initially active field is claimName (cyan). After Tab should move to valueType. + stdin.write(TAB); + await delay(); + + // Value type should be the highlighted field now + expect(lastFrame()).toContain('String'); // default valueType rendered as 'String' + }); + + it('right arrow cycles value type from STRING to STRING_ARRAY', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Tab to valueType field + stdin.write(TAB); + await delay(); + + // Right arrow to cycle to STRING_ARRAY + stdin.write(RIGHT_ARROW); + await delay(); + + expect(lastFrame()).toContain('String Array'); + }); + + it('left arrow on STRING wraps to STRING_ARRAY', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Tab to valueType field + stdin.write(TAB); + await delay(); + + // Left arrow wraps around + stdin.write(LEFT_ARROW); + await delay(); + + expect(lastFrame()).toContain('String Array'); + }); + + it('right arrow cycles operator EQUALS → CONTAINS', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Tab twice: claimName → valueType → operator + stdin.write(TAB); + await delay(); + stdin.write(TAB); + await delay(); + + // Now on operator field, default is Equals. Right arrow cycles to Contains. + stdin.write(RIGHT_ARROW); + await delay(); + + expect(lastFrame()).toContain('Contains'); + }); + + it('right arrow cycles operator CONTAINS → CONTAINS_ANY', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Tab twice to operator + stdin.write(TAB); + await delay(); + stdin.write(TAB); + await delay(); + + // Cycle twice: Equals → Contains → Contains Any + stdin.write(RIGHT_ARROW); + await delay(); + stdin.write(RIGHT_ARROW); + await delay(); + + expect(lastFrame()).toContain('Contains Any'); + }); + + it('Enter with empty claimName shows "Claim name is required" error', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Press Enter without filling in any fields + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('Claim name is required'); + }); + + it('Enter with claimName but empty matchValue shows "Match value is required" error', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClaimForm(stdin); + + // Type a claim name + for (const ch of 'department') { + stdin.write(ch); + } + await delay(); + + // Press Enter without filling matchValue + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('Match value is required'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 5: Custom Claims Manager +// ───────────────────────────────────────────────────────────────────────────── + +describe('Custom Claims Manager', () => { + // Helper: navigate to custom claims manager and add one complete claim + async function addOneClaim(stdin: ReturnType['stdin']) { + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + + // Select only customClaims (index 3) + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(DOWN_ARROW); + await delay(); + stdin.write(SPACE); + await delay(); + stdin.write(ENTER); + await delay(); + + // Now in CustomClaimForm (add mode). Fill out claimName. + for (const ch of 'role') { + stdin.write(ch); + } + await delay(); + + // Tab to valueType, keep STRING + stdin.write(TAB); + await delay(); + // Tab to operator, keep EQUALS + stdin.write(TAB); + await delay(); + // Tab to matchValue + stdin.write(TAB); + await delay(); + for (const ch of 'admin') { + stdin.write(ch); + } + await delay(); + + // Enter to save + stdin.write(ENTER); + await delay(); + // Now back in list mode with one claim + } + + it('after adding a claim, shows numbered list with claim summary', async () => { + const { lastFrame, stdin } = render(); + + await addOneClaim(stdin); + + const frame = lastFrame()!; + expect(frame).toContain('1.'); // numbered list entry + expect(frame).toContain('role'); + }); + + it('shows Add claim, Edit existing claim, and Done actions after first claim', async () => { + const { lastFrame, stdin } = render(); + + await addOneClaim(stdin); + + const frame = lastFrame()!; + expect(frame).toContain('Add claim'); + expect(frame).toContain('Edit existing claim'); + expect(frame).toContain('Done'); + }); + + it('selecting Done completes the custom claims step and moves on', async () => { + const { lastFrame, stdin } = render(); + + await addOneClaim(stdin); + + // Navigate to Done (index 3 in list: Add claim=0, Edit=1, Delete=2, Done=3) + stdin.write(DOWN_ARROW); // Add → Edit + await delay(); + stdin.write(DOWN_ARROW); // Edit → Delete + await delay(); + stdin.write(DOWN_ARROW); // Delete → Done + await delay(); + stdin.write(ENTER); // Select Done + await delay(); + + // Should be on clientId sub-step + expect(lastFrame()).toContain('OAuth Client ID'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 6: Confirm Review with JWT +// ───────────────────────────────────────────────────────────────────────────── + +describe('Confirm Review with JWT', () => { + // Helper: complete the full JWT flow with audience constraint only, skip client credentials + async function completeJwtFlowWithAudience(stdin: ReturnType['stdin']) { + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + await selectAudienceConstraint(stdin); // select audience only + + // Audience input + for (const ch of 'aud-test') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + // clientId — skip (press Enter on empty) + stdin.write(ENTER); + await delay(); + + // advanced-config step — press Enter to accept defaults + stdin.write(ENTER); + await delay(); + } + + it('confirm screen shows Discovery URL and configured constraints', async () => { + const { lastFrame, stdin } = render(); + + await completeJwtFlowWithAudience(stdin); + + const frame = lastFrame()!; + expect(frame).toContain('Discovery URL'); + expect(frame).toContain('https://example.com/.well-known/openid-configuration'); + }); + + it('confirm screen shows Allowed Audience value', async () => { + const { lastFrame, stdin } = render(); + + await completeJwtFlowWithAudience(stdin); + + expect(lastFrame()).toContain('Allowed Audience'); + expect(lastFrame()).toContain('aud-test'); + }); + + it('confirm screen shows human-readable authorizer label', async () => { + const { lastFrame, stdin } = render(); + + await completeJwtFlowWithAudience(stdin); + + expect(lastFrame()).toContain('Custom JWT'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Group 7: Optional Client Credentials +// ───────────────────────────────────────────────────────────────────────────── + +describe('Optional Client Credentials', () => { + // Helper: navigate to clientId sub-step with audience constraint satisfied + async function navigateToClientId(stdin: ReturnType['stdin']) { + await navigateToJwtConfig(stdin); + await enterDiscoveryUrl(stdin); + await selectAudienceConstraint(stdin); + + // Provide audience + for (const ch of 'aud-123') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + // Now on clientId sub-step + } + + it('shows optional OAuth credentials prompt at clientId step', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClientId(stdin); + + expect(lastFrame()).toContain('OAuth Client ID'); + expect(lastFrame()).toContain('Optional'); + }); + + it('skipping client ID (empty Enter) proceeds without credentials', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClientId(stdin); + + // Press Enter with no input to skip + stdin.write(ENTER); + await delay(); + + // Should advance past jwt-config to advanced-config (or include-targets if present) + // With no unassigned targets, it goes to advanced-config + expect(lastFrame()).toContain('Advanced Configuration'); + }); + + it('providing client ID advances to client secret prompt', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClientId(stdin); + + for (const ch of 'my-client-id') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + expect(lastFrame()).toContain('OAuth Client Secret'); + }); + + it('providing client ID and secret includes them in the config review', async () => { + const { lastFrame, stdin } = render(); + + await navigateToClientId(stdin); + + // Enter client ID + for (const ch of 'my-client-id') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + // Enter client secret + for (const ch of 'my-client-secret') { + stdin.write(ch); + } + await delay(); + stdin.write(ENTER); + await delay(); + + // Advanced config — accept defaults + stdin.write(ENTER); + await delay(); + + // Should be on confirm review, showing gateway credential entry + expect(lastFrame()).toContain('Gateway Credential'); + }); +}); diff --git a/src/cli/tui/screens/mcp/__tests__/finishJwtConfig.test.ts b/src/cli/tui/screens/mcp/__tests__/finishJwtConfig.test.ts new file mode 100644 index 000000000..f2364b736 --- /dev/null +++ b/src/cli/tui/screens/mcp/__tests__/finishJwtConfig.test.ts @@ -0,0 +1,66 @@ +import { CustomJwtAuthorizerConfigSchema } from '../../../../../schema'; +import { describe, expect, it } from 'vitest'; + +describe('finishJwtConfig data mapping', () => { + it('STRING_ARRAY claim produces matchValueStringList shape accepted by schema', () => { + const config = { + discoveryUrl: 'https://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + customClaims: [ + { + inboundTokenClaimName: 'groups', + inboundTokenClaimValueType: 'STRING_ARRAY' as const, + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY' as const, + claimMatchValue: { + matchValueStringList: ['admin', 'dev'], + }, + }, + }, + ], + }; + const result = CustomJwtAuthorizerConfigSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('STRING claim produces matchValueString shape accepted by schema', () => { + const config = { + discoveryUrl: 'https://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + customClaims: [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING' as const, + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS' as const, + claimMatchValue: { + matchValueString: 'engineering', + }, + }, + }, + ], + }; + const result = CustomJwtAuthorizerConfigSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it('STRING_ARRAY with matchValueString instead of matchValueStringList is rejected', () => { + const config = { + discoveryUrl: 'https://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + customClaims: [ + { + inboundTokenClaimName: 'groups', + inboundTokenClaimValueType: 'STRING_ARRAY' as const, + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY' as const, + claimMatchValue: { + matchValueString: 'admin,dev', + }, + }, + }, + ], + }; + // Schema accepts this structurally (both fields are optional at schema level) + // but the TUI should map STRING_ARRAY to matchValueStringList, not matchValueString + const result = CustomJwtAuthorizerConfigSchema.safeParse(config); + expect(result.success).toBe(true); + }); +}); diff --git a/src/cli/tui/screens/mcp/__tests__/useAddGatewayWizard.test.tsx b/src/cli/tui/screens/mcp/__tests__/useAddGatewayWizard.test.tsx index 1ac03f01a..c4907c72e 100644 --- a/src/cli/tui/screens/mcp/__tests__/useAddGatewayWizard.test.tsx +++ b/src/cli/tui/screens/mcp/__tests__/useAddGatewayWizard.test.tsx @@ -28,23 +28,54 @@ interface HarnessHandle { setAdvancedConfig: (opts: { enableSemanticSearch: boolean; exceptionLevel: GatewayExceptionLevel }) => void; setName: (name: string) => void; setAuthorizerType: (type: 'NONE' | 'AWS_IAM' | 'CUSTOM_JWT') => void; + setJwtConfig: (config: { + discoveryUrl: string; + allowedAudience: string[]; + allowedClients: string[]; + allowedScopes?: string[]; + customClaims?: { + inboundTokenClaimName: string; + inboundTokenClaimValueType: 'STRING' | 'STRING_ARRAY'; + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS' | 'CONTAINS' | 'CONTAINS_ANY'; + claimMatchValue: { + matchValueString?: string; + matchValueStringList?: string[]; + }; + }; + }[]; + clientId?: string; + clientSecret?: string; + }) => void; + goBack: () => void; } -const ImperativeHarness = React.forwardRef((_, ref) => { - const wizard = useAddGatewayWizard(); - useImperativeHandle(ref, () => ({ - setAdvancedConfig: wizard.setAdvancedConfig, - setName: wizard.setName, - setAuthorizerType: wizard.setAuthorizerType, - })); - return ( - - exceptionLevel:{wizard.config.exceptionLevel} - enableSemanticSearch:{String(wizard.config.enableSemanticSearch)} - step:{wizard.step} - - ); -}); +interface ImperativeHarnessProps { + unassignedTargetsCount?: number; +} + +const ImperativeHarness = React.forwardRef( + ({ unassignedTargetsCount = 0 }, ref) => { + const wizard = useAddGatewayWizard(unassignedTargetsCount); + useImperativeHandle(ref, () => ({ + setAdvancedConfig: wizard.setAdvancedConfig, + setName: wizard.setName, + setAuthorizerType: wizard.setAuthorizerType, + setJwtConfig: wizard.setJwtConfig, + goBack: wizard.goBack, + })); + return ( + + exceptionLevel:{wizard.config.exceptionLevel} + enableSemanticSearch:{String(wizard.config.enableSemanticSearch)} + step:{wizard.step} + authorizerType:{wizard.config.authorizerType} + jwtConfig:{JSON.stringify(wizard.config.jwtConfig ?? null)} + steps:{wizard.steps.join(',')} + + ); + } +); ImperativeHarness.displayName = 'ImperativeHarness'; // --------------------------------------------------------------------------- @@ -116,4 +147,201 @@ describe('useAddGatewayWizard', () => { expect(lastFrame()).toContain('step:confirm'); }); }); + + describe('JWT config flow', () => { + it("setAuthorizerType('CUSTOM_JWT') sets step to jwt-config", () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + + expect(lastFrame()).toContain('step:jwt-config'); + }); + + it("setAuthorizerType('CUSTOM_JWT') preserves existing jwtConfig", () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }); + }); + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + + // jwtConfig should still be set (not null) after switching back to CUSTOM_JWT + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).not.toContain('jwtConfig:null'); + expect(frame).toContain('"discoveryUrl"'); + }); + + it("setAuthorizerType('NONE') clears jwtConfig", () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }); + }); + act(() => { + ref.current!.setAuthorizerType('NONE'); + }); + + expect(lastFrame()).toContain('jwtConfig:null'); + }); + + it('setJwtConfig with full config advances to advanced-config when no unassigned targets', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + allowedScopes: ['openid'], + clientId: 'my-client', + clientSecret: 'my-secret', + }); + }); + + expect(lastFrame()).toContain('step:advanced-config'); + }); + + it('setJwtConfig builds correct config object with only selected constraints', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }); + }); + + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('"discoveryUrl"'); + expect(frame).toContain('"allowedAudience"'); + expect(frame).toContain('"allowedClients"'); + // optional fields not provided should be absent + expect(frame).not.toContain('"allowedScopes"'); + expect(frame).not.toContain('"clientId"'); + }); + + it('setJwtConfig preserves customClaims in config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: [], + allowedClients: [], + customClaims: [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + }, + ], + }); + }); + + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('"customClaims"'); + expect(frame).toContain('"department"'); + expect(frame).toContain('"EQUALS"'); + }); + }); + + describe('step navigation with JWT', () => { + it("steps include 'jwt-config' when authorizerType is CUSTOM_JWT", () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + + expect(lastFrame()).toContain('jwt-config'); + }); + + it("steps don't include 'jwt-config' when authorizerType is NONE", () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('NONE'); + }); + + // steps list should not contain jwt-config + const frame = lastFrame()!.replace(/\n/g, ''); + expect(frame).toContain('name,authorizer,advanced-config,confirm'); + expect(frame).not.toContain('jwt-config'); + }); + + it('goBack from jwt-config returns to authorizer', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + expect(lastFrame()).toContain('step:jwt-config'); + + act(() => { + ref.current!.goBack(); + }); + + expect(lastFrame()).toContain('step:authorizer'); + }); + }); + + describe('JWT config with targets', () => { + it('when unassigned targets exist, setJwtConfig advances to include-targets instead of advanced-config', () => { + const ref = React.createRef(); + const { lastFrame } = render(); + + act(() => { + ref.current!.setAuthorizerType('CUSTOM_JWT'); + }); + act(() => { + ref.current!.setJwtConfig({ + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + allowedClients: ['client1'], + }); + }); + + expect(lastFrame()).toContain('step:include-targets'); + }); + }); }); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index f834dd0c2..14195d6f5 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -1,5 +1,6 @@ import type { ApiGatewayHttpMethod, + CustomClaimValidation, GatewayAuthorizerType, GatewayExceptionLevel, GatewayPolicyEngineConfiguration, @@ -35,6 +36,7 @@ export interface AddGatewayConfig { allowedAudience?: string[]; allowedClients?: string[]; allowedScopes?: string[]; + customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; }; diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index 00137862c..b9d600ba5 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -1,4 +1,4 @@ -import type { GatewayAuthorizerType, GatewayExceptionLevel, PolicyEngineMode } from '../../../../schema'; +import type { CustomClaimValidation, GatewayAuthorizerType, GatewayExceptionLevel, PolicyEngineMode } from '../../../../schema'; import type { AddGatewayConfig, AddGatewayStep } from './types'; import { useCallback, useMemo, useState } from 'react'; @@ -84,6 +84,7 @@ export function useAddGatewayWizard(unassignedTargetsCount = 0, policyEngineCoun allowedAudience?: string[]; allowedClients?: string[]; allowedScopes?: string[]; + customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; }) => { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 8e4396373..b06fcc7fc 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -3,6 +3,8 @@ import { AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema, ApiGatewayConfigSchema, + ClaimMatchValueSchema, + CustomClaimValidationSchema, CustomJwtAuthorizerConfigSchema, GatewayAuthorizerTypeSchema, GatewayExceptionLevelSchema, @@ -84,51 +86,55 @@ describe('CustomJwtAuthorizerConfigSchema', () => { expect(result.success).toBe(false); }); - it('rejects HTTP discovery URL (HTTPS required)', () => { + it('accepts empty allowedClients when other constraints present', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ ...validConfig, - discoveryUrl: 'http://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + allowedClients: [], }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); - it('rejects unknown fields (strict)', () => { + it('accepts empty allowedAudience when other constraints present', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ ...validConfig, - unknownField: 'not allowed', + allowedAudience: [], }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); - it('accepts config with only allowedScopes (audience and clients optional)', () => { + it('rejects when all constraint fields are empty or absent', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ discoveryUrl: validConfig.discoveryUrl, - allowedScopes: ['read', 'write'], + allowedAudience: [], + allowedClients: [], + allowedScopes: [], }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); - it('rejects config with no audience, clients, or scopes', () => { + it('accepts config with only allowedScopes', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ discoveryUrl: validConfig.discoveryUrl, + allowedScopes: ['openid'], }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); - it('accepts config with only allowedClients', () => { + it('rejects http:// discovery URL', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ - discoveryUrl: validConfig.discoveryUrl, - allowedClients: ['client-id-1'], + discoveryUrl: 'http://example.com/.well-known/openid-configuration', + allowedAudience: ['client-id-1'], }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); - it('accepts config with only allowedAudience', () => { + it('rejects extra fields (strict)', () => { const result = CustomJwtAuthorizerConfigSchema.safeParse({ - discoveryUrl: validConfig.discoveryUrl, - allowedAudience: ['aud-1'], + discoveryUrl: 'https://cognito-idp.us-east-1.amazonaws.com/pool123/.well-known/openid-configuration', + allowedAudience: ['client-id-1'], + extraField: 'not-allowed', }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); }); @@ -1021,3 +1027,43 @@ describe('AgentCoreGatewayTargetSchema with outbound auth', () => { expect(result.success).toBe(false); }); }); + +describe('ClaimMatchValueSchema', () => { + it('accepts matchValueString only', () => { + const result = ClaimMatchValueSchema.safeParse({ matchValueString: 'admin' }); + expect(result.success).toBe(true); + }); + + it('accepts matchValueStringList only', () => { + const result = ClaimMatchValueSchema.safeParse({ matchValueStringList: ['admin', 'dev'] }); + expect(result.success).toBe(true); + }); + + it('rejects when both matchValueString and matchValueStringList are provided', () => { + const result = ClaimMatchValueSchema.safeParse({ + matchValueString: 'admin', + matchValueStringList: ['admin', 'dev'], + }); + expect(result.success).toBe(false); + }); + + it('rejects when neither matchValueString nor matchValueStringList is provided', () => { + const result = ClaimMatchValueSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('CustomClaimValidationSchema', () => { + it('rejects extra fields (strict)', () => { + const result = CustomClaimValidationSchema.safeParse({ + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + extraField: 'not-allowed', + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 4d9389a8a..ddc75c531 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -1,4 +1,5 @@ import { DeploymentTargetNameSchema } from './aws-targets'; +import { CustomClaimValidationSchema } from './mcp'; import { z } from 'zod'; // ============================================================================ @@ -89,6 +90,7 @@ export const CustomJwtAuthorizerSchema = ExternallyManagedResourceSchema.extend( allowedAudience: z.array(z.string()).optional(), allowedClients: z.array(z.string()).optional(), allowedScopes: z.array(z.string()).optional(), + customClaims: z.array(CustomClaimValidationSchema).optional(), discoveryUrl: z.string(), }); diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 24fcf9f02..d9a393fdf 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -43,11 +43,49 @@ const OidcDiscoveryUrlSchema = z message: `OIDC discovery URL must end with '${OIDC_WELL_KNOWN_SUFFIX}'`, }); +// ── Custom Claims Schemas (matches CFN CustomClaimValidationType) ── + +export const ClaimMatchOperatorSchema = z.enum(['EQUALS', 'CONTAINS', 'CONTAINS_ANY']); +export type ClaimMatchOperator = z.infer; + +export const ClaimMatchValueSchema = z + .object({ + matchValueString: z.string().min(1).optional(), + matchValueStringList: z.array(z.string().min(1)).min(1).max(255).optional(), + }) + .refine(data => data.matchValueString !== undefined || data.matchValueStringList !== undefined, { + message: 'Either matchValueString or matchValueStringList must be provided', + }) + .refine(data => !(data.matchValueString !== undefined && data.matchValueStringList !== undefined), { + message: 'Only one of matchValueString or matchValueStringList may be provided', + }); +export type ClaimMatchValue = z.infer; + +export const InboundTokenClaimValueTypeSchema = z.enum(['STRING', 'STRING_ARRAY']); +export type InboundTokenClaimValueType = z.infer; + +export const CustomClaimValidationSchema = z + .object({ + inboundTokenClaimName: z + .string() + .min(1) + .regex(/^[A-Za-z0-9_.\-:]+$/, 'Claim name must match [A-Za-z0-9_.-:]+'), + inboundTokenClaimValueType: InboundTokenClaimValueTypeSchema, + authorizingClaimMatchValue: z.object({ + claimMatchOperator: ClaimMatchOperatorSchema, + claimMatchValue: ClaimMatchValueSchema, + }), + }) + .strict(); +export type CustomClaimValidation = z.infer; + +// ── Custom JWT Authorizer Configuration ── + /** * Custom JWT authorizer configuration. * Used when authorizerType is 'CUSTOM_JWT'. * - * At least one of allowedAudience, allowedClients, or allowedScopes + * At least one of allowedAudience, allowedClients, allowedScopes, or customClaims * must be provided. Only discoveryUrl is unconditionally required. */ export const CustomJwtAuthorizerConfigSchema = z @@ -60,17 +98,20 @@ export const CustomJwtAuthorizerConfigSchema = z allowedClients: z.array(z.string().min(1)).optional(), /** List of allowed scopes */ allowedScopes: z.array(z.string().min(1)).optional(), + /** Custom claim validations */ + customClaims: z.array(CustomClaimValidationSchema).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; + const hasClaims = data.customClaims && data.customClaims.length > 0; - if (!hasAudience && !hasClients && !hasScopes) { + if (!hasAudience && !hasClients && !hasScopes && !hasClaims) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'At least one of allowedAudience, allowedClients, or allowedScopes must be provided', + message: 'At least one of allowedAudience, allowedClients, allowedScopes, or customClaims must be provided', }); } }); From 0b14f04422b8f2eee5614135518737f0ed55b7dd Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Mar 2026 10:33:33 -0400 Subject: [PATCH 2/7] fix(gateway): improve custom claim form navigation UX Enter now advances to the next field instead of immediately submitting, and up/down arrow keys navigate between fields for a more intuitive form experience. --- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 28 ++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 21a204b4a..59da3be92 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -307,7 +307,7 @@ export function AddGatewayScreen({ ? HELP_TEXT.MULTI_SELECT : jwtSubStep === 'customClaims' ? claimsManagerMode === 'add' || claimsManagerMode === 'edit' - ? 'Tab field · ←/→ cycle · Enter save · Esc cancel' + ? '↑/↓ field · ←/→ cycle · Enter next/save · Esc cancel' : 'Navigate · Enter select · Esc back' : HELP_TEXT.TEXT_INPUT : isIncludeTargetsStep || isAdvancedConfigStep @@ -914,10 +914,10 @@ function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProp return; } - // Tab / Shift+Tab to cycle fields - if (key.tab) { + // Tab / Shift+Tab / Up / Down to cycle fields + if (key.tab || key.upArrow || key.downArrow) { const idx = CLAIM_FIELDS.indexOf(activeField); - if (key.shift) { + if (key.shift || key.upArrow) { setActiveField(CLAIM_FIELDS[(idx - 1 + CLAIM_FIELDS.length) % CLAIM_FIELDS.length]!); } else { setActiveField(CLAIM_FIELDS[(idx + 1) % CLAIM_FIELDS.length]!); @@ -926,8 +926,26 @@ function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProp return; } - // Enter to submit + // Enter: advance to next field, or submit on the last field if (key.return) { + const idx = CLAIM_FIELDS.indexOf(activeField); + if (idx < CLAIM_FIELDS.length - 1) { + // Validate current field before advancing + if (activeField === 'claimName') { + if (!claimName.trim()) { + setError('Claim name is required'); + return; + } + if (!/^[A-Za-z0-9_.\-:]+$/.test(claimName.trim())) { + setError('Claim name may only contain letters, digits, _, ., -, :'); + return; + } + } + setActiveField(CLAIM_FIELDS[idx + 1]!); + setError(null); + return; + } + // Last field — submit if (!claimName.trim()) { setError('Claim name is required'); return; From 6e072928805878bb62f7ac761885f7f9cf947f89 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Mar 2026 10:59:18 -0400 Subject: [PATCH 3/7] fix(gateway): position cursor before placeholder in custom claim form When a text field is empty, the cursor now appears before the placeholder hint instead of after it, matching expected input behavior. --- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 59da3be92..9093223f1 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -1017,10 +1017,11 @@ function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProp Claim name: + {activeField === 'claimName' && !claimName && } {claimName || e.g., department} - {activeField === 'claimName' && } + {activeField === 'claimName' && claimName && } @@ -1051,6 +1052,7 @@ function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProp Match value: + {activeField === 'matchValue' && !matchValue && } {matchValue || ( @@ -1058,7 +1060,7 @@ function CustomClaimForm({ initialClaim, onSave, onCancel }: CustomClaimFormProp )} - {activeField === 'matchValue' && } + {activeField === 'matchValue' && matchValue && } From 47b5a3bc1efa2cabecc25d66e0eb8925e4929183 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Mar 2026 11:10:40 -0400 Subject: [PATCH 4/7] test(gateway): update claim form test for Enter-advances-fields behavior The test expected Enter to immediately submit and show a validation error, but Enter now advances to the next field. Updated the test to press Enter through all fields before expecting the submission validation error. --- .../screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx index 2d7d3ad72..56afd3f70 100644 --- a/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx +++ b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx @@ -409,8 +409,14 @@ describe('Custom Claims Form', () => { } await delay(); - // Press Enter without filling matchValue - stdin.write(ENTER); + // Enter advances through fields: claimName -> valueType -> operator -> matchValue + stdin.write(ENTER); // advance to valueType + await delay(); + stdin.write(ENTER); // advance to operator + await delay(); + stdin.write(ENTER); // advance to matchValue + await delay(); + stdin.write(ENTER); // submit on last field — matchValue is empty await delay(); expect(lastFrame()).toContain('Match value is required'); From f956200f6cff5aaf32937e61463832dd654ccf37 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 23 Mar 2026 16:01:59 -0400 Subject: [PATCH 5/7] fix: restore CLIENT_ID env var and move inline import to top-level Restore writing both CLIENT_ID and CLIENT_SECRET to .env in createManagedOAuthCredential, matching main branch behavior. Move dynamic import of policyEnginePrimitive to a static top-level import per AGENTS.md conventions. --- src/cli/primitives/GatewayPrimitive.ts | 7 ++++--- src/cli/tui/screens/policy/AddPolicyScreen.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index 4faa64014..dd07932f8 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -425,9 +425,10 @@ export class GatewayPrimitive extends BasePrimitive Date: Mon, 23 Mar 2026 16:29:18 -0400 Subject: [PATCH 6/7] style: run prettier and fix test prop Run prettier on 3 files and add missing existingPolicyEngines prop to AddGatewayJwtConfig test defaults. --- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 1 + .../tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx | 1 + src/cli/tui/screens/mcp/useAddGatewayWizard.ts | 7 ++++++- src/cli/tui/screens/policy/AddPolicyScreen.tsx | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 9093223f1..aa3b2cef3 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -558,6 +558,7 @@ interface JwtConfigInputProps { onBack: () => void; onClaimsManagerModeChange?: (mode: ClaimsManagerMode) => void; } + function JwtConfigInput({ subStep, steps, diff --git a/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx index 56afd3f70..39bfdaa34 100644 --- a/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx +++ b/src/cli/tui/screens/mcp/__tests__/AddGatewayJwtConfig.test.tsx @@ -17,6 +17,7 @@ const DEFAULT_PROPS = { onExit: vi.fn(), existingGateways: [], unassignedTargets: [], + existingPolicyEngines: [], }; // Helper: navigate past the name step by pressing Enter to accept the generated default name diff --git a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts index b9d600ba5..debcce302 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayWizard.ts @@ -1,4 +1,9 @@ -import type { CustomClaimValidation, GatewayAuthorizerType, GatewayExceptionLevel, PolicyEngineMode } from '../../../../schema'; +import type { + CustomClaimValidation, + GatewayAuthorizerType, + GatewayExceptionLevel, + PolicyEngineMode, +} from '../../../../schema'; import type { AddGatewayConfig, AddGatewayStep } from './types'; import { useCallback, useMemo, useState } from 'react'; diff --git a/src/cli/tui/screens/policy/AddPolicyScreen.tsx b/src/cli/tui/screens/policy/AddPolicyScreen.tsx index 3e4a921ad..38005228d 100644 --- a/src/cli/tui/screens/policy/AddPolicyScreen.tsx +++ b/src/cli/tui/screens/policy/AddPolicyScreen.tsx @@ -1,6 +1,7 @@ import { PolicyNameSchema } from '../../../../schema'; import { detectRegion } from '../../../aws'; import { getPolicyGeneration, startPolicyGeneration } from '../../../aws/policy-generation'; +import { policyEnginePrimitive } from '../../../primitives/registry'; import { ConfirmReview, Panel, PathInput, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; @@ -9,7 +10,6 @@ import { generateUniqueName } from '../../utils'; import type { AddPolicyConfig, PolicySourceMethod } from './types'; import { POLICY_SOURCE_METHOD_OPTIONS, POLICY_STEP_LABELS, VALIDATION_MODE_OPTIONS } from './types'; import { useAddPolicyWizard } from './useAddPolicyWizard'; -import { policyEnginePrimitive } from '../../../primitives/registry'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; From 1fb146dd57f9dc652ec8e8983213a767b159589c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 24 Mar 2026 13:51:38 -0400 Subject: [PATCH 7/7] ci: retrigger checks