Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions devenv.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 54 additions & 12 deletions internal/cmd/jwks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}

Expand All @@ -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",
)
}
214 changes: 214 additions & 0 deletions internal/cmd/jwks_test.go
Original file line number Diff line number Diff line change
@@ -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)`)
})
}
Loading
Loading