diff --git a/CHANGELOG.md b/CHANGELOG.md index c786726..64f5f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Requests: multi-sink output — typed Result as source of truth, console via internal/view Doc, and `--format json` (schemaVersion, no ANSI). MCP requests returns JSON. + Jwks: multi-sink output — typed Result as source of truth, console via internal/view Doc, and `--format json` (schemaVersion, no ANSI). MCP generate_jwks returns JSON. Synchronize allowedCLICommands format flag in MCP. + + Requests: add `followRedirects` configuration option (defaulting to false) in JSON schema and Go client to control HTTP 3xx redirection. Requests: adopt traffic-light color progression for HTTP status codes (2xx green, 3xx yellow, 4xx peach, 5xx red). @@ -34,8 +37,18 @@ Jwtinfo: add typed errors for base64 JWT parts, JWT parse sources, and invalid request-values JSON. +### Next Steps + + Documentation: Document `--format json` across all diagnostic subcommands (certinfo, jwtinfo, requests, jwks) in README.md. + + Requests: Deprecate legacy imperative `printTLSInfo` in `internal/requests/requests.go` in favor of `internal/requests/view.go` Doc rendering. + + Testing: Expand devenv integration scripts for `certinfo` and `jwtinfo` `--format json` to match `requests` and `jwks`. + ### Fix + Jwks: return error from RunE on unsupported format so command execution exits with status 1. + Cmd: write JSON output to cmd.OutOrStdout() directly via fmt.Fprintln instead of cmd.Println to ensure payloads go to stdout. Requests: gate response body output on `printResponseBody` so configuring `responseBodyMatchRegexp` does not inadvertently dump the response body. diff --git a/devenv.nix b/devenv.nix index 4b81b8b..baff8a3 100644 --- a/devenv.nix +++ b/devenv.nix @@ -505,6 +505,15 @@ in ! printf '%s\n' "$out" | grep -q $'\x1b' ''; + scripts.test-jwks-format-json.exec = '' + gum format "## test jwks --format json output" + set -eo pipefail + out=$(./dist/https-wrench jwks --public-key-file ./internal/jwtinfo/testdata/rsa-pkcs8-public-key.pem --format json) + printf '%s\n' "$out" | jq -e '.schemaVersion == "1" and .command == "jwks" and (.keys | length > 0)' > /dev/null + ! printf '%s\n' "$out" | grep -q $'\x1b' + ''; + + scripts.test-requests-quiet.exec = '' gum format "## test request quiet mode (verbose: false)" set -eo pipefail diff --git a/internal/cmd/jwks.go b/internal/cmd/jwks.go index fcc7c91..7c8df33 100644 --- a/internal/cmd/jwks.go +++ b/internal/cmd/jwks.go @@ -2,49 +2,84 @@ package cmd import ( "fmt" + "os" "github.com/spf13/cobra" "github.com/xenos76/https-wrench/internal/errdisp" "github.com/xenos76/https-wrench/internal/jwks" - "github.com/xenos76/https-wrench/internal/style" + "github.com/xenos76/https-wrench/internal/view" + "golang.org/x/term" ) var ( jwksPublicKeyFile string jwksKID string + jwksFmt string ) var jwksCmd = &cobra.Command{ Use: "jwks", Short: "Generate a JSON Web Key Set (JWKS) from a public key", - Long: `Generate a pretty-printed JSON Web Key Set (JWKS) from a public key file. + Long: `Generate a JSON Web Key Set (JWKS) from a public key file. The generated JWKS contains only public key parameters and is safe to be exposed (e.g. at a /.well-known/jwks.json endpoint). +Output formats: + text (default) — styled console view (Banner + highlighted JSON) + json — machine-readable report (schemaVersion, command, keys, no ANSI) + Examples: # Generate a public JWKS from an RSA public key https-wrench jwks --public-key-file rsa-public.pem # Generate a public JWKS with a custom Key ID (kid) https-wrench jwks --public-key-file ec-public.pem --kid "my-custom-key-id" + + # Emit machine-readable JSON (agents / MCP) + https-wrench jwks --public-key-file rsa-public.pem --format json `, - Run: func(cmd *cobra.Command, _ []string) { - jwksJSON, err := jwks.GenerateJWKS(cmd.Context(), jwksPublicKeyFile, jwksKID) + RunE: func(cmd *cobra.Command, _ []string) error { + switch jwksFmt { + case "", "text", "json": + default: + return fmt.Errorf("unsupported --format %q (use text or json)", jwksFmt) + } + + result, err := jwks.Generate(cmd.Context(), jwksPublicKeyFile, jwksKID) if err != nil { cmd.PrintErrf("Error generating JWKS: %s\n", errdisp.FormatCause(err)) - return + return nil } - // Print a nice title and then the formatted JSON - w := cmd.OutOrStdout() - fmt.Fprintln(w) - fmt.Fprintln(w, style.LgSprintf(style.Cmd, "Jwks")) - fmt.Fprintln(w) + out := cmd.OutOrStdout() + + if jwksFmt == "json" { + payload, encErr := jwks.EncodeJSON(result) + if encErr != nil { + cmd.Printf("error encoding JWKS JSON: %s\n", errdisp.FormatCause(encErr)) + + return nil + } + + _, _ = fmt.Fprintln(out, string(payload)) + + return nil + } - fmt.Fprint(w, style.CodeSyntaxHighlight("json", jwksJSON)) - fmt.Fprintln(w) + opts := view.Options{} + if f, ok := out.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + opts.ForceColor = true + } + + if err = view.Render(out, jwks.BuildDoc(result), opts); err != nil { + cmd.Printf("error rendering JWKS: %s\n", errdisp.FormatCause(err)) + + return nil + } + + return nil }, } @@ -65,4 +100,11 @@ func init() { "", "Optional explicit Key ID (kid) to use. If not provided, a SHA-256-derived ID is generated.", ) + + jwksCmd.Flags().StringVar( + &jwksFmt, + "format", + "text", + "Output format: text (default) or json", + ) } diff --git a/internal/cmd/jwks_test.go b/internal/cmd/jwks_test.go new file mode 100644 index 0000000..696867b --- /dev/null +++ b/internal/cmd/jwks_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/jwks" +) + +func writeTestRSAPublicKeyPEM(t *testing.T) string { + t.Helper() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + require.NoError(t, err) + + block := &pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubDER, + } + + path := filepath.Join(t.TempDir(), "rsa_public.pem") + f, err := os.Create(path) + require.NoError(t, err) + + defer f.Close() + + err = pem.Encode(f, block) + require.NoError(t, err) + + return path +} + +func resetJWKSFlags() { + jwksPublicKeyFile = "" + jwksKID = "" + jwksFmt = "text" +} + +func TestJWKSCmd_Errors(t *testing.T) { + t.Parallel() + + t.Run("unsupported format", func(t *testing.T) { + resetJWKSFlags() + + jwksPublicKeyFile = "some.pem" + jwksFmt = "yaml" + + out := new(bytes.Buffer) + jwksCmd.SetOut(out) + jwksCmd.SetErr(out) + jwksCmd.SetContext(context.Background()) + + err := jwksCmd.RunE(jwksCmd, nil) + require.Error(t, err) + require.Contains(t, err.Error(), `unsupported --format "yaml" (use text or json)`) + }) + + t.Run("invalid file", func(t *testing.T) { + resetJWKSFlags() + + jwksPublicKeyFile = "non_existent_file.pem" + + errOut := new(bytes.Buffer) + jwksCmd.SetErr(errOut) + jwksCmd.SetContext(context.Background()) + + err := jwksCmd.RunE(jwksCmd, nil) + require.NoError(t, err) + + got := errOut.String() + require.Contains(t, got, "Error generating JWKS:") + require.Contains(t, got, "no such file or directory") + }) +} + +func TestJWKSCmd_Success(t *testing.T) { + pubFile := writeTestRSAPublicKeyPEM(t) + + t.Run("default text format", func(t *testing.T) { + resetJWKSFlags() + + jwksPublicKeyFile = pubFile + + out := new(bytes.Buffer) + jwksCmd.SetOut(out) + jwksCmd.SetErr(out) + jwksCmd.SetContext(context.Background()) + + err := jwksCmd.RunE(jwksCmd, nil) + require.NoError(t, err) + + got := out.String() + require.Contains(t, got, "Jwks") + require.Contains(t, got, `"kty": "RSA"`) + require.Contains(t, got, `"keys"`) + }) + + t.Run("explicit kid text format", func(t *testing.T) { + resetJWKSFlags() + + jwksPublicKeyFile = pubFile + jwksKID = "custom-kid-123" + + out := new(bytes.Buffer) + jwksCmd.SetOut(out) + jwksCmd.SetErr(out) + jwksCmd.SetContext(context.Background()) + + err := jwksCmd.RunE(jwksCmd, nil) + require.NoError(t, err) + + got := out.String() + require.Contains(t, got, "custom-kid-123") + }) + + t.Run("json format", func(t *testing.T) { + resetJWKSFlags() + + jwksPublicKeyFile = pubFile + jwksFmt = "json" + + out := new(bytes.Buffer) + jwksCmd.SetOut(out) + jwksCmd.SetErr(out) + jwksCmd.SetContext(context.Background()) + + err := jwksCmd.RunE(jwksCmd, nil) + require.NoError(t, err) + + got := out.String() + require.NotContains(t, got, "\x1b[", "JSON output must not contain ANSI escape codes") + + var res jwks.Result + + err = json.Unmarshal([]byte(got), &res) + require.NoError(t, err) + require.Equal(t, jwks.ResultSchemaVersion, res.SchemaVersion) + require.Equal(t, "jwks", res.Command) + require.Len(t, res.Keys, 1) + require.Contains(t, string(res.Keys[0]), `"kty": "RSA"`) + }) +} + +//nolint:revive +func TestJWKSCmd_Execution_UnsupportedFormat(t *testing.T) { + if os.Getenv("TEST_JWKS_EXIT") == "1" { + rootCmd.SetArgs(os.Args[3:]) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } + + os.Exit(0) + } + + pubFile := writeTestRSAPublicKeyPEM(t) + + t.Run("Execute returns error on unsupported format", func(t *testing.T) { + t.Cleanup(func() { + resetJWKSFlags() + rootCmd.SetArgs(nil) + + _ = jwksCmd.Flags().Set("format", "text") + _ = jwksCmd.Flags().Set("public-key-file", "") + _ = jwksCmd.Flags().Set("kid", "") + }) + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"jwks", "--public-key-file", pubFile, "--format", "yaml"}) + + err := rootCmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), `unsupported --format "yaml" (use text or json)`) + }) + + t.Run("subprocess fails with exit status 1", func(t *testing.T) { + cmd := exec.Command( + os.Args[0], + "-test.run=^TestJWKSCmd_Execution_UnsupportedFormat$", + "--", + "jwks", + "--public-key-file", + pubFile, + "--format", + "yaml", + ) + + cmd.Env = append(os.Environ(), "TEST_JWKS_EXIT=1") + + out, err := cmd.CombinedOutput() + require.Error(t, err) + + var exitErr *exec.ExitError + + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) + require.Contains(t, string(out), `unsupported --format "yaml" (use text or json)`) + }) +} diff --git a/internal/jwks/jwks.go b/internal/jwks/jwks.go index 6f2c08b..86c71f6 100644 --- a/internal/jwks/jwks.go +++ b/internal/jwks/jwks.go @@ -2,7 +2,6 @@ package jwks import ( - "bytes" "context" "crypto/ecdsa" "crypto/ed25519" @@ -18,22 +17,22 @@ import ( "github.com/MicahParks/jwkset" ) -// GenerateJWKS reads a public key from a file, parses it, and returns its JSON Web Key Set (JWKS) representation. +// Generate reads a public key from a file, parses it, and returns its typed Result. // If kid is provided, it sets the Key ID explicitly; otherwise, it computes a SHA-256-derived kid from the public key. -func GenerateJWKS(ctx context.Context, publicKeyFile string, kid string) (string, error) { +func Generate(ctx context.Context, publicKeyFile string, kid string) (*Result, error) { keyPEM, err := os.ReadFile(publicKeyFile) if err != nil { - return "", fmt.Errorf("unable to read public key from %s: %w", publicKeyFile, err) + return nil, fmt.Errorf("unable to read public key from %s: %w", publicKeyFile, err) } block, _ := pem.Decode(keyPEM) if block == nil { - return "", ErrPEMDecode + return nil, ErrPEMDecode } key, err := jwkset.LoadX509KeyInfer(block) if err != nil { - return "", fmt.Errorf("%w: %w", ErrUnsupportedPublicKey, err) + return nil, fmt.Errorf("%w: %w", ErrUnsupportedPublicKey, err) } // Ensure the key is a public key @@ -41,13 +40,13 @@ func GenerateJWKS(ctx context.Context, publicKeyFile string, kid string) (string case *rsa.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey: // Valid public key types default: - return "", ErrNotPublicKey + return nil, ErrNotPublicKey } if kid == "" { pubBytes, err := x509.MarshalPKIXPublicKey(key) if err != nil { - return "", fmt.Errorf("failed to marshal public key for SHA-256-derived kid: %w", err) + return nil, fmt.Errorf("failed to marshal public key for SHA-256-derived kid: %w", err) } hash := sha256.Sum256(pubBytes) @@ -63,7 +62,7 @@ func GenerateJWKS(ctx context.Context, publicKeyFile string, kid string) (string // Create JWK from the parsed public key jwk, err := jwkset.NewJWKFromKey(key, options) if err != nil { - return "", fmt.Errorf("failed to create JWK from public key: %w", err) + return nil, fmt.Errorf("failed to create JWK from public key: %w", err) } // Initialize in-memory storage for the JWK Set @@ -71,19 +70,35 @@ func GenerateJWKS(ctx context.Context, publicKeyFile string, kid string) (string err = storage.KeyWrite(ctx, jwk) if err != nil { - return "", fmt.Errorf("failed to write key to JWK Set storage: %w", err) + return nil, fmt.Errorf("failed to write key to JWK Set storage: %w", err) } jwksBytes, err := storage.JSONPublic(ctx) if err != nil { - return "", fmt.Errorf("failed to generate JWKS JSON: %w", err) + return nil, fmt.Errorf("failed to generate JWKS JSON: %w", err) + } + + var parsed struct { + Keys []json.RawMessage `json:"keys"` + } + if err := json.Unmarshal(jwksBytes, &parsed); err != nil { + return nil, fmt.Errorf("failed to parse generated JWKS JSON: %w", err) } - // Pretty-print the JSON - var prettyJWKS bytes.Buffer - if err := json.Indent(&prettyJWKS, jwksBytes, "", " "); err != nil { - return string(jwksBytes), nil + return &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + Keys: parsed.Keys, + }, nil +} + +// GenerateJWKS reads a public key from a file, parses it, and returns its JSON Web Key Set (JWKS) representation. +// If kid is provided, it sets the Key ID explicitly; otherwise, it computes a SHA-256-derived kid from the public key. +func GenerateJWKS(ctx context.Context, publicKeyFile string, kid string) (string, error) { + res, err := Generate(ctx, publicKeyFile, kid) + if err != nil { + return "", err } - return prettyJWKS.String(), nil + return res.JWKSJSON() } diff --git a/internal/jwks/jwks_test.go b/internal/jwks/jwks_test.go index 6a63f0a..8cce496 100644 --- a/internal/jwks/jwks_test.go +++ b/internal/jwks/jwks_test.go @@ -16,47 +16,46 @@ import ( "github.com/stretchr/testify/require" ) -func TestGenerateJWKS_Success(t *testing.T) { - tmpDir := t.TempDir() +func createTestPEM(t *testing.T, tmpDir, filename, blockType string, bytes []byte) string { + t.Helper() - // Helper to create a PEM file - createPEM := func(t *testing.T, filename, blockType string, bytes []byte) string { - t.Helper() + path := filepath.Join(tmpDir, filename) + block := &pem.Block{ + Type: blockType, + Bytes: bytes, + } + file, err := os.Create(path) + require.NoError(t, err) + err = pem.Encode(file, block) + require.NoError(t, err) + require.NoError(t, file.Close()) - path := filepath.Join(tmpDir, filename) - block := &pem.Block{ - Type: blockType, - Bytes: bytes, - } - file, err := os.Create(path) - require.NoError(t, err) - err = pem.Encode(file, block) - require.NoError(t, err) - require.NoError(t, file.Close()) + return path +} - return path - } +func TestGenerateJWKS_Success(t *testing.T) { + tmpDir := t.TempDir() // RSA Setup rsaPriv, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) rsaPubBytes, err := x509.MarshalPKIXPublicKey(&rsaPriv.PublicKey) require.NoError(t, err) - rsaPubFile := createPEM(t, "rsa_public.pem", "PUBLIC KEY", rsaPubBytes) + rsaPubFile := createTestPEM(t, tmpDir, "rsa_public.pem", "PUBLIC KEY", rsaPubBytes) // ECDSA Setup ecdsaPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) ecdsaPubBytes, err := x509.MarshalPKIXPublicKey(&ecdsaPriv.PublicKey) require.NoError(t, err) - ecdsaPubFile := createPEM(t, "ecdsa_public.pem", "PUBLIC KEY", ecdsaPubBytes) + ecdsaPubFile := createTestPEM(t, tmpDir, "ecdsa_public.pem", "PUBLIC KEY", ecdsaPubBytes) // Ed25519 Setup edPub, _, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) edPubBytes, err := x509.MarshalPKIXPublicKey(edPub) require.NoError(t, err) - edPubFile := createPEM(t, "ed25519_public.pem", "PUBLIC KEY", edPubBytes) + edPubFile := createTestPEM(t, tmpDir, "ed25519_public.pem", "PUBLIC KEY", edPubBytes) t.Run("RSA", func(t *testing.T) { jwksJSON, err := GenerateJWKS(context.Background(), rsaPubFile, "") @@ -87,6 +86,16 @@ func TestGenerateJWKS_Success(t *testing.T) { require.NoError(t, err) require.Contains(t, jwksJSON, `"kid": "`+expectedKID+`"`) }) + + t.Run("Generate Result", func(t *testing.T) { + res, err := Generate(context.Background(), rsaPubFile, "test-kid") + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, ResultSchemaVersion, res.SchemaVersion) + require.Equal(t, resultCommand, res.Command) + require.Len(t, res.Keys, 1) + require.Contains(t, string(res.Keys[0]), `"test-kid"`) + }) } func TestGenerateJWKS_Errors(t *testing.T) { diff --git a/internal/jwks/result.go b/internal/jwks/result.go new file mode 100644 index 0000000..092ffe1 --- /dev/null +++ b/internal/jwks/result.go @@ -0,0 +1,53 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package jwks + +import ( + "encoding/json" + "errors" +) + +const ( + // ResultSchemaVersion is the JSON export schema version for jwks results. + ResultSchemaVersion = "1" + resultCommand = "jwks" +) + +// Result is the serializable jwks report (source of truth for JSON and console Doc). +type Result struct { + SchemaVersion string `json:"schemaVersion"` + Command string `json:"command"` + Keys []json.RawMessage `json:"keys"` +} + +// EncodeJSON writes the result as indented JSON with no ANSI. +func EncodeJSON(r *Result) ([]byte, error) { + if r == nil { + return nil, errors.New("jwks: nil result") + } + + return json.MarshalIndent(r, "", " ") +} + +// JWKSJSON returns the standard RFC 7517 JWK Set JSON ({"keys": [...]}) +// pretty-printed without the tool result envelope. +func (r *Result) JWKSJSON() (string, error) { + if r == nil { + return "", errors.New("jwks: nil result") + } + + raw := struct { + Keys []json.RawMessage `json:"keys"` + }{ + Keys: r.Keys, + } + + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/internal/jwks/result_test.go b/internal/jwks/result_test.go new file mode 100644 index 0000000..df98a54 --- /dev/null +++ b/internal/jwks/result_test.go @@ -0,0 +1,67 @@ +package jwks + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResult_EncodeJSON(t *testing.T) { + t.Parallel() + + res := &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + Keys: []json.RawMessage{ + json.RawMessage(`{"kty":"RSA","kid":"test-key-id","n":"abc","e":"AQAB"}`), + }, + } + + payload, err := EncodeJSON(res) + require.NoError(t, err) + + got := string(payload) + require.Contains(t, got, `"schemaVersion": "1"`) + require.Contains(t, got, `"command": "jwks"`) + require.Contains(t, got, `"test-key-id"`) + require.NotContains(t, got, "\x1b[", "JSON output must not contain ANSI escape sequences") + + var unmarshaled map[string]any + + err = json.Unmarshal(payload, &unmarshaled) + require.NoError(t, err) + require.Equal(t, ResultSchemaVersion, unmarshaled["schemaVersion"]) + require.Equal(t, resultCommand, unmarshaled["command"]) +} + +func TestResult_JWKSJSON(t *testing.T) { + t.Parallel() + + res := &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + Keys: []json.RawMessage{ + json.RawMessage(`{"kty":"EC","kid":"ec-key-id","crv":"P-256"}`), + }, + } + + rawJSON, err := res.JWKSJSON() + require.NoError(t, err) + require.Contains(t, rawJSON, `"keys"`) + require.Contains(t, rawJSON, `"ec-key-id"`) + require.NotContains(t, rawJSON, "schemaVersion") + require.NotContains(t, rawJSON, "\x1b[") +} + +func TestResult_NilGuards(t *testing.T) { + t.Parallel() + + _, err := EncodeJSON(nil) + require.Error(t, err) + + var nilResult *Result + + _, err = nilResult.JWKSJSON() + require.Error(t, err) +} diff --git a/internal/jwks/view.go b/internal/jwks/view.go new file mode 100644 index 0000000..5b83741 --- /dev/null +++ b/internal/jwks/view.go @@ -0,0 +1,31 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package jwks + +import ( + "github.com/xenos76/https-wrench/internal/view" +) + +// BuildDoc builds a console view document from a Result. +func BuildDoc(r *Result) view.Doc { + nodes := []view.Node{ + view.Blank{}, + view.Banner{Text: "Jwks"}, + view.Blank{}, + } + + if r == nil { + return view.Doc{Nodes: nodes} + } + + body, err := r.JWKSJSON() + if err != nil { + body = "{}" + } + + nodes = append(nodes, view.Code{Lang: "json", Body: body}) + + return view.Doc{Nodes: nodes} +} diff --git a/internal/jwks/view_test.go b/internal/jwks/view_test.go new file mode 100644 index 0000000..b7e2fa3 --- /dev/null +++ b/internal/jwks/view_test.go @@ -0,0 +1,48 @@ +package jwks + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/view" +) + +func TestBuildDoc_NilResult(t *testing.T) { + t.Parallel() + + doc := BuildDoc(nil) + require.Len(t, doc.Nodes, 3) + + var buf bytes.Buffer + + err := view.Render(&buf, doc, view.Options{}) + require.NoError(t, err) + require.Contains(t, buf.String(), "Jwks") +} + +func TestBuildDoc_WithResult(t *testing.T) { + t.Parallel() + + res := &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + Keys: []json.RawMessage{ + json.RawMessage(`{"kty":"RSA","kid":"key-1"}`), + }, + } + + doc := BuildDoc(res) + require.Len(t, doc.Nodes, 4) + + var buf bytes.Buffer + + err := view.Render(&buf, doc, view.Options{}) + require.NoError(t, err) + + out := buf.String() + require.Contains(t, out, "Jwks") + require.Contains(t, out, `"keys"`) + require.Contains(t, out, `"key-1"`) +} diff --git a/internal/mcp/coverage_test.go b/internal/mcp/coverage_test.go index 7f813ac..8365a69 100644 --- a/internal/mcp/coverage_test.go +++ b/internal/mcp/coverage_test.go @@ -129,15 +129,19 @@ func TestBuildCLICommand(t *testing.T) { cmd, errs := buildCLICommand("jwks", map[string]string{ "public-key-file": "/keys/pub.pem", "kid": "my-kid", + "format": "json", }) require.Empty(t, errs) require.Contains(t, cmd, "https-wrench jwks") + require.Contains(t, cmd, "--format json") - _, errs = buildCLICommand("jwtinfo", map[string]string{"token-file": "t.jwt"}) + cmd, errs = buildCLICommand("jwtinfo", map[string]string{"token-file": "t.jwt", "format": "json"}) require.Empty(t, errs) + require.Contains(t, cmd, "--format json") - _, errs = buildCLICommand("requests", map[string]string{"config": "cfg.yaml"}) + cmd, errs = buildCLICommand("requests", map[string]string{"config": "cfg.yaml", "format": "json"}) require.Empty(t, errs) + require.Contains(t, cmd, "--format json") _, errs = buildCLICommand("certinfo", map[string]string{"unknown-flag": "x"}) require.NotEmpty(t, errs) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 069605c..c1b7953 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -304,18 +304,20 @@ var allowedCLICommands = map[string]cliCommandDef{ "token-file": {}, "request-url": {}, "request-values": {}, "request-values-json": {}, "request-values-file": {}, "validation-url": {}, "refresh": {}, "token-output-file": {}, "renew-threshold": {}, + "format": {}, }, }, "jwks": { requiredFlags: []string{"public-key-file"}, allowedFlags: map[string]struct{}{ - "public-key-file": {}, "kid": {}, + "public-key-file": {}, "kid": {}, "format": {}, }, }, "requests": { oneOfGroups: [][]string{{"config", "show-sample-config"}}, allowedFlags: map[string]struct{}{ "config": {}, "show-sample-config": {}, "ca-bundle": {}, + "format": {}, }, }, } diff --git a/internal/mcp/tools_exec.go b/internal/mcp/tools_exec.go index a92eee7..fb4144c 100644 --- a/internal/mcp/tools_exec.go +++ b/internal/mcp/tools_exec.go @@ -349,12 +349,17 @@ func executeGenerateJWKS(ctx context.Context, input generateJWKSInput) (execTool return execToolOutput{}, &RequiredFieldError{Field: "publicKeyFile"} } - jwksJSON, err := jwks.GenerateJWKS(ctx, input.PublicKeyFile, input.Kid) + result, err := jwks.Generate(ctx, input.PublicKeyFile, input.Kid) if err != nil { return execToolOutput{}, err } - return execToolOutput{Output: jwksJSON}, nil + payload, err := jwks.EncodeJSON(result) + if err != nil { + return execToolOutput{}, err + } + + return execToolOutput{Output: string(payload)}, nil } func loadConfigYAML(configYAML, configPath string) (string, error) { diff --git a/internal/mcp/tools_exec_test.go b/internal/mcp/tools_exec_test.go index 8bd48ea..d0a3ae2 100644 --- a/internal/mcp/tools_exec_test.go +++ b/internal/mcp/tools_exec_test.go @@ -28,6 +28,8 @@ func TestGenerateJwksTool(t *testing.T) { "publicKeyFile": pubFile, }) require.Empty(t, out["error"]) + require.Contains(t, out["output"], `"schemaVersion": "1"`) + require.Contains(t, out["output"], `"command": "jwks"`) require.Contains(t, out["output"], `"keys"`) }