From 282a0eedf3d688724441011996e2613bfd605936 Mon Sep 17 00:00:00 2001 From: Zeno Belli Date: Sun, 13 Sep 2026 20:59:26 +0200 Subject: [PATCH 1/2] refactor(jwtinfo): multi-sink output via view Doc and JSON Separate collect from present with a typed Result, render console through internal/view with syntax highlighting, and add --format json for agents/MCP. --- internal/cmd/jwtinfo.go | 54 +++++++++++++-- internal/cmd/jwtinfo_test.go | 62 ++++++++++++++--- internal/jwtinfo/jwtinfo.go | 97 +++----------------------- internal/jwtinfo/jwtinfo_test.go | 10 ++- internal/jwtinfo/result.go | 108 +++++++++++++++++++++++++++++ internal/jwtinfo/result_test.go | 112 +++++++++++++++++++++++++++++++ internal/jwtinfo/view.go | 94 ++++++++++++++++++++++++++ internal/mcp/coverage_test.go | 3 +- internal/mcp/tools_exec.go | 15 +++-- internal/mcp/tools_exec_test.go | 3 +- internal/view/render.go | 6 ++ internal/view/render_test.go | 30 +++++++++ 12 files changed, 477 insertions(+), 117 deletions(-) create mode 100644 internal/jwtinfo/result.go create mode 100644 internal/jwtinfo/result_test.go create mode 100644 internal/jwtinfo/view.go diff --git a/internal/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go index cb85c4b..8cad9d1 100644 --- a/internal/cmd/jwtinfo.go +++ b/internal/cmd/jwtinfo.go @@ -6,7 +6,6 @@ package cmd import ( "context" - "fmt" "io" "net/http" "os" @@ -17,7 +16,8 @@ import ( "github.com/spf13/cobra" "github.com/xenos76/https-wrench/internal/errdisp" "github.com/xenos76/https-wrench/internal/jwtinfo" - "github.com/xenos76/https-wrench/internal/style" + "github.com/xenos76/https-wrench/internal/view" + "golang.org/x/term" ) var ( @@ -36,6 +36,7 @@ var ( refresh bool tokenOutputFile string renewThreshold float64 + jwtinfoFmt string keyfuncDefOverride keyfunc.Override // requestSteps tracks the sequence of request-related flags as they appear on the command line. @@ -99,6 +100,9 @@ Examples: --request-values-json $REQ_VALUES \ --validation-url $VALIDATION_URL + # Emit machine-readable JSON (agents / MCP) + https-wrench jwtinfo --token-file ./token.jwt --format json + # Request a JWT token, write it to a file and refresh it before expiration https-wrench jwtinfo \ --request-url $REQ_URL \ @@ -114,8 +118,15 @@ Examples: requestValuesMap = make(map[string]string) ) + switch jwtinfoFmt { + case "", "text", "json": + default: + cmd.Printf("Error: unsupported --format %q (use text or json)\n", jwtinfoFmt) + return + } + if refresh && requestURL == "" { - fmt.Fprintln(cmd.OutOrStdout(), style.LgSprintf(style.Error, "Error: --refresh requires --request-url")) + cmd.Print("Error: --refresh requires --request-url\n") return } @@ -187,14 +198,36 @@ Examples: } } - err = jwtinfo.PrintTokenInfo(tokenData, cmd.OutOrStdout()) - if err != nil { - cmd.Printf("error while printing token data: %s\n", errdisp.FormatCause(err)) + result, buildErr := tokenData.BuildResult() + if buildErr != nil { + cmd.Printf("error building Jwtinfo result: %s\n", errdisp.FormatCause(buildErr)) return } + out := cmd.OutOrStdout() + + if jwtinfoFmt == "json" { + payload, encErr := jwtinfo.EncodeJSON(result) + if encErr != nil { + cmd.Printf("error encoding Jwtinfo JSON: %s\n", errdisp.FormatCause(encErr)) + return + } + + cmd.Println(string(payload)) + } else { + opts := view.Options{} + if f, ok := out.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + opts.ForceColor = true + } + + if err = view.Render(out, jwtinfo.BuildDoc(result), opts); err != nil { + cmd.Printf("error while printing token data: %s\n", errdisp.FormatCause(err)) + return + } + } + if tokenOutputFile != "" { - tokenData.WriteTokenToFile(tokenOutputFile, cmd.OutOrStdout()) + tokenData.WriteTokenToFile(tokenOutputFile, out) } if refresh { @@ -297,6 +330,13 @@ func init() { "Percentage of token lifetime to wait before refreshing", ) + jwtinfoCmd.Flags().StringVar( + &jwtinfoFmt, + "format", + "text", + "Output format: text (default) or json", + ) + // Either read a token from a file or request it from an HTTP address jwtinfoCmd.MarkFlagsMutuallyExclusive(flagNameTokenFile, flagNameRequestURL) jwtinfoCmd.MarkFlagsOneRequired(flagNameTokenFile, flagNameRequestURL) diff --git a/internal/cmd/jwtinfo_test.go b/internal/cmd/jwtinfo_test.go index c067a25..993feb5 100644 --- a/internal/cmd/jwtinfo_test.go +++ b/internal/cmd/jwtinfo_test.go @@ -3,12 +3,14 @@ package cmd import ( "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/spf13/pflag" "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/jwtinfo" ) func TestJwtinfoCmd_Errors(t *testing.T) { @@ -33,6 +35,14 @@ func TestJwtinfoCmd_Errors(t *testing.T) { }, expected: []string{"Error: --refresh requires --request-url"}, }, + { + name: "unsupported format", + setup: func() { + tokenFile = "some.jwt" + jwtinfoFmt = "yaml" + }, + expected: []string{"Error: unsupported --format \"yaml\" (use text or json)"}, + }, } for _, tt := range tests { @@ -68,21 +78,50 @@ func TestJwtinfoCmd_Success(t *testing.T) { })) defer ts.Close() - resetFlags() + t.Run("default text format", func(t *testing.T) { + resetFlags() + + requestURL = ts.URL + requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} - requestURL = ts.URL - requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} + out := new(bytes.Buffer) + jwtinfoCmd.SetOut(out) + jwtinfoCmd.SetErr(out) + jwtinfoCmd.SetContext(context.Background()) - out := new(bytes.Buffer) - jwtinfoCmd.SetOut(out) - jwtinfoCmd.SetErr(out) - jwtinfoCmd.SetContext(context.Background()) + jwtinfoCmd.Run(jwtinfoCmd, nil) - jwtinfoCmd.Run(jwtinfoCmd, nil) + got := out.String() + require.Contains(t, got, "JwtInfo") + require.Contains(t, got, "AccessToken") + require.Contains(t, got, "\"sub\"") + require.Contains(t, got, "\"1234567890\"") + }) - got := out.String() - require.Contains(t, got, "\"sub\"") - require.Contains(t, got, "\"1234567890\"") + t.Run("json format", func(t *testing.T) { + resetFlags() + + requestURL = ts.URL + requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} + jwtinfoFmt = "json" + + out := new(bytes.Buffer) + jwtinfoCmd.SetOut(out) + jwtinfoCmd.SetErr(out) + jwtinfoCmd.SetContext(context.Background()) + + jwtinfoCmd.Run(jwtinfoCmd, nil) + + got := out.String() + require.NotContains(t, got, "\x1b[") + + var res jwtinfo.Result + require.NoError(t, json.Unmarshal(out.Bytes(), &res)) + require.Equal(t, jwtinfo.ResultSchemaVersion, res.SchemaVersion) + require.Equal(t, "jwtinfo", res.Command) + require.NotNil(t, res.AccessToken) + require.Contains(t, string(res.AccessToken.Claims), "1234567890") + }) } func resetFlags() { @@ -98,4 +137,5 @@ func resetFlags() { jwksURL = "" tokenOutputFile = "" renewThreshold = 80.0 + jwtinfoFmt = "text" } diff --git a/internal/jwtinfo/jwtinfo.go b/internal/jwtinfo/jwtinfo.go index 987c599..f147389 100644 --- a/internal/jwtinfo/jwtinfo.go +++ b/internal/jwtinfo/jwtinfo.go @@ -18,13 +18,11 @@ import ( "time" "github.com/MicahParks/keyfunc/v3" - "github.com/charmbracelet/lipgloss/table" "github.com/golang-jwt/jwt/v5" - "github.com/xenos76/https-wrench/internal/style" + "github.com/xenos76/https-wrench/internal/view" ) var ( - chromaStyle = "catppuccin-frappe" emptyString string userAgent = "HTTPS-Wrench/JwtInfo" ) @@ -335,94 +333,19 @@ func (jtd *JwtTokenData) ParseWithJWKS(ctx context.Context, jwksURL string, keyf } // PrintTokenInfo prints the decoded JWT token information (headers and claims) -// to the provided writer in a human-readable format. -// -//nolint:revive +// to the provided writer in a human-readable format with forced color. func PrintTokenInfo(jtd *JwtTokenData, w io.Writer) error { - sl := style.CertKeyP4.Render - sv := style.CertValue.Render - sTrue := style.BoolTrue.Render - sFalse := style.BoolFalse.Render - - fmt.Fprintln(w) - fmt.Fprintln(w, style.LgSprintf(style.Cmd, "JwtInfo")) - fmt.Fprintln(w) - - validString := sFalse("false") - if jtd.AccessTokenJwt != nil && jtd.AccessTokenJwt.Valid { - validString = sTrue("true") - } - - tokens := []struct { - name string - header []byte - claims []byte - }{ - { - name: "AccessToken", - header: jtd.AccessTokenHeader, - claims: jtd.AccessTokenClaims, - }, - { - name: "RefreshToken", - header: jtd.RefreshTokenHeader, - claims: jtd.RefreshTokenClaims, - }, - } - - for _, token := range tokens { - if len(token.header) == 0 { - continue - } - - fmt.Fprintln(w, style.LgSprintf(style.Title2, "%s", token.name)) - fmt.Fprintln(w) - - if token.name == "AccessToken" && jtd.AccessTokenJwt != nil { - fmt.Fprintln(w, style.LgSprintf(style.ItemKey, "Valid %s", validString)) - fmt.Fprintln(w) - } - - fmt.Fprintln(w, style.LgSprintf(style.ItemKey, "Header")) - - var prettyJSON bytes.Buffer - - err := json.Indent(&prettyJSON, token.header, "", " ") - if err != nil { - prettyJSON.Write(token.header) - } - - headerCode := prettyJSON.String() - - fmt.Fprint(w, style.CodeSyntaxHighlightWithStyle("json", headerCode, chromaStyle)) - prettyJSON.Reset() - - fmt.Fprintln(w) - fmt.Fprintln(w, style.LgSprintf(style.ItemKey, "Claims")) - - tokenTimeClaims, err := unmarshalTokenTimeClaims(token.claims) - if err != nil { - return fmt.Errorf("unable to unmarshal time claims from %s: %w", token.name, err) - } - - cTable := table.New().Border(style.LGDefBorder) - cTable.Row(sl("Issued At"), sv(tokenTimeClaims["iat"])) - cTable.Row(sl("Expiration Time"), sv(tokenTimeClaims["exp"])) - fmt.Fprintln(w, cTable.Render()) - cTable.ClearRows() - - err = json.Indent(&prettyJSON, token.claims, "", " ") - if err != nil { - prettyJSON.Write(token.claims) - } - - claimsCode := prettyJSON.String() + return PrintTokenInfoWithOptions(jtd, w, view.Options{ForceColor: true}) +} - fmt.Fprint(w, style.CodeSyntaxHighlightWithStyle("json", claimsCode, chromaStyle)) - fmt.Fprintln(w) +// PrintTokenInfoWithOptions builds a Result and renders the console Doc with opts. +func PrintTokenInfoWithOptions(jtd *JwtTokenData, w io.Writer, opts view.Options) error { + result, err := jtd.BuildResult() + if err != nil { + return err } - return nil + return view.Render(w, BuildDoc(result), opts) } // unmarshalTokenTimeClaims extracts and converts numeric "iat" and "exp" claims diff --git a/internal/jwtinfo/jwtinfo_test.go b/internal/jwtinfo/jwtinfo_test.go index 6dbd8b7..ba2b5eb 100644 --- a/internal/jwtinfo/jwtinfo_test.go +++ b/internal/jwtinfo/jwtinfo_test.go @@ -17,6 +17,7 @@ import ( "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/view" ) func TestReadRequestValuesFile(t *testing.T) { @@ -1028,8 +1029,7 @@ func TestReadTokenFromFile(t *testing.T) { } func TestPrintTokenInfo_Errors(t *testing.T) { - t.Run("jsonIndent error header", func(t *testing.T) { - //nolint:revive + t.Run("invalid header still renders", func(t *testing.T) { buffer := bytes.Buffer{} // Valid claims so unmarshalTokenTimeClaims succeeds. @@ -1042,12 +1042,10 @@ func TestPrintTokenInfo_Errors(t *testing.T) { AccessTokenClaims: []byte(claimsJSON), } - err := PrintTokenInfo(&jtd, &buffer) + err := PrintTokenInfoWithOptions(&jtd, &buffer, view.Options{Plain: true}) require.NoError(t, err) - - // The json.Indent for header failed and it wrote the raw header. - // It will be syntax-highlighted, adding ANSI codes, so we just check it wrote something. require.Positive(t, buffer.Len()) + require.Contains(t, buffer.String(), "invalid json") }) t.Run("unmarshalTokenTimeClaims error", func(t *testing.T) { diff --git a/internal/jwtinfo/result.go b/internal/jwtinfo/result.go new file mode 100644 index 0000000..d11be73 --- /dev/null +++ b/internal/jwtinfo/result.go @@ -0,0 +1,108 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package jwtinfo + +import ( + "encoding/json" + "errors" + "fmt" +) + +const ( + // ResultSchemaVersion is the JSON export schema version for jwtinfo results. + ResultSchemaVersion = "1" + resultCommand = "jwtinfo" +) + +// Result is the serializable jwtinfo report (source of truth for JSON and console Doc). +type Result struct { + SchemaVersion string `json:"schemaVersion"` + Command string `json:"command"` + AccessToken *TokenSection `json:"accessToken,omitempty"` + RefreshToken *TokenSection `json:"refreshToken,omitempty"` +} + +// TokenSection holds plain token metadata for sinks (no ANSI). +type TokenSection struct { + Valid *bool `json:"valid,omitempty"` + IssuedAt string `json:"issuedAt"` + ExpirationTime string `json:"expirationTime"` + Header json.RawMessage `json:"header"` + Claims json.RawMessage `json:"claims"` +} + +// BuildResult gathers a serializable report from JwtTokenData. It does not request +// tokens or perform I/O. +func (jtd *JwtTokenData) BuildResult() (*Result, error) { + if jtd == nil { + return nil, errors.New("jwtinfo: nil token data") + } + + r := &Result{ + SchemaVersion: ResultSchemaVersion, + Command: resultCommand, + } + + if len(jtd.AccessTokenHeader) > 0 { + sec, err := tokenSection(jtd.AccessTokenHeader, jtd.AccessTokenClaims, "AccessToken") + if err != nil { + return nil, err + } + + if jtd.AccessTokenJwt != nil { + valid := jtd.AccessTokenJwt.Valid + sec.Valid = &valid + } + + r.AccessToken = sec + } + + if len(jtd.RefreshTokenHeader) > 0 { + sec, err := tokenSection(jtd.RefreshTokenHeader, jtd.RefreshTokenClaims, "RefreshToken") + if err != nil { + return nil, err + } + + r.RefreshToken = sec + } + + return r, nil +} + +// EncodeJSON writes the result as indented JSON with no ANSI. +func EncodeJSON(r *Result) ([]byte, error) { + if r == nil { + return nil, errors.New("jwtinfo: nil result") + } + + return json.MarshalIndent(r, "", " ") +} + +func tokenSection(header, claims []byte, name string) (*TokenSection, error) { + timeClaims, err := unmarshalTokenTimeClaims(claims) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal time claims from %s: %w", name, err) + } + + return &TokenSection{ + IssuedAt: timeClaims["iat"], + ExpirationTime: timeClaims["exp"], + Header: asRawMessage(header), + Claims: asRawMessage(claims), + }, nil +} + +func asRawMessage(b []byte) json.RawMessage { + if json.Valid(b) { + return json.RawMessage(append([]byte(nil), b...)) + } + + enc, err := json.Marshal(string(b)) + if err != nil { + return json.RawMessage(`""`) + } + + return enc +} diff --git a/internal/jwtinfo/result_test.go b/internal/jwtinfo/result_test.go new file mode 100644 index 0000000..e72932f --- /dev/null +++ b/internal/jwtinfo/result_test.go @@ -0,0 +1,112 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package jwtinfo + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "github.com/xenos76/https-wrench/internal/view" +) + +func TestBuildResult_EncodeJSON(t *testing.T) { + t.Parallel() + + now := time.Now().Unix() + exp := now + 3600 + header := []byte(`{"alg":"RS256","typ":"JWT"}`) + claims := []byte(fmt.Sprintf(`{"iat":%d,"exp":%d,"sub":"demo"}`, now, exp)) + + jtd := &JwtTokenData{ + AccessTokenHeader: header, + AccessTokenClaims: claims, + } + + result, err := jtd.BuildResult() + require.NoError(t, err) + require.Equal(t, ResultSchemaVersion, result.SchemaVersion) + require.Equal(t, "jwtinfo", result.Command) + require.NotNil(t, result.AccessToken) + require.Nil(t, result.AccessToken.Valid) + require.Nil(t, result.RefreshToken) + require.Equal(t, "demo", mustClaim(t, result.AccessToken.Claims, "sub")) + + payload, err := EncodeJSON(result) + require.NoError(t, err) + require.NotContains(t, string(payload), "\x1b[") + + var decoded Result + require.NoError(t, json.Unmarshal(payload, &decoded)) + require.Equal(t, ResultSchemaVersion, decoded.SchemaVersion) + require.Equal(t, "jwtinfo", decoded.Command) + require.NotNil(t, decoded.AccessToken) + require.Contains(t, string(decoded.AccessToken.Header), "RS256") +} + +func TestBuildResult_ValidWhenParsed(t *testing.T) { + t.Parallel() + + now := time.Now().Unix() + exp := now + 3600 + jtd := &JwtTokenData{ + AccessTokenHeader: []byte(`{"alg":"RS256","typ":"JWT"}`), + AccessTokenClaims: []byte(fmt.Sprintf(`{"iat":%d,"exp":%d}`, now, exp)), + AccessTokenJwt: &jwt.Token{Valid: true}, + } + + result, err := jtd.BuildResult() + require.NoError(t, err) + require.NotNil(t, result.AccessToken.Valid) + require.True(t, *result.AccessToken.Valid) +} + +func TestBuildDoc_PlainHasNoANSI(t *testing.T) { + t.Parallel() + + now := time.Now().Unix() + exp := now + 3600 + jtd := &JwtTokenData{ + AccessTokenHeader: []byte(`{"alg":"RS256","typ":"JWT"}`), + AccessTokenClaims: []byte(fmt.Sprintf(`{"iat":%d,"exp":%d}`, now, exp)), + AccessTokenJwt: &jwt.Token{Valid: true}, + } + + var buf bytes.Buffer + require.NoError(t, PrintTokenInfoWithOptions(jtd, &buf, view.Options{Plain: true})) + + got := buf.String() + require.Contains(t, got, "JwtInfo") + require.Contains(t, got, "AccessToken") + require.Contains(t, got, "Valid") + require.Contains(t, got, "Issued At") + require.Contains(t, got, "Expiration Time") + require.Contains(t, got, "Header") + require.Contains(t, got, "Claims") + require.Contains(t, got, "RS256") + require.NotContains(t, got, "\x1b[") +} + +func TestEncodeJSON_NilResult(t *testing.T) { + t.Parallel() + + _, err := EncodeJSON(nil) + require.Error(t, err) +} + +func mustClaim(t *testing.T, raw json.RawMessage, key string) string { + t.Helper() + + var m map[string]any + require.NoError(t, json.Unmarshal(raw, &m)) + v, ok := m[key].(string) + require.True(t, ok) + + return v +} diff --git a/internal/jwtinfo/view.go b/internal/jwtinfo/view.go new file mode 100644 index 0000000..c55d125 --- /dev/null +++ b/internal/jwtinfo/view.go @@ -0,0 +1,94 @@ +/* +Copyright © 2026 Zeno Belli xeno@os76.xyz +*/ + +package jwtinfo + +import ( + "bytes" + "encoding/json" + + "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: "JwtInfo"}, + view.Blank{}, + } + + if r == nil { + return view.Doc{Nodes: nodes} + } + + if r.AccessToken != nil { + nodes = append(nodes, tokenSectionDoc("AccessToken", r.AccessToken)...) + } + + if r.RefreshToken != nil { + nodes = append(nodes, tokenSectionDoc("RefreshToken", r.RefreshToken)...) + } + + return view.Doc{Nodes: nodes} +} + +func tokenSectionDoc(name string, sec *TokenSection) []view.Node { + kids := make([]view.Node, 0, 8) + + if sec.Valid != nil { + tone := view.ToneBoolFalse + val := "false" + + if *sec.Valid { + tone = view.ToneBoolTrue + val = "true" + } + + kids = append(kids, view.KV{Key: "Valid", Value: val, Tone: tone}) + } + + kids = append(kids, + view.Section{ + Title: "Header", + Level: 2, + Kids: []view.Node{ + view.Code{Lang: "json", Body: prettyJSON(sec.Header)}, + }, + }, + view.Section{ + Title: "Claims", + Level: 2, + Kids: []view.Node{ + view.Table{ + Rows: [][]view.Cell{ + { + {Text: "Issued At", Tone: view.ToneKey}, + {Text: sec.IssuedAt, Tone: view.ToneValue}, + }, + { + {Text: "Expiration Time", Tone: view.ToneKey}, + {Text: sec.ExpirationTime, Tone: view.ToneValue}, + }, + }, + }, + view.Code{Lang: "json", Body: prettyJSON(sec.Claims)}, + }, + }, + ) + + return []view.Node{ + view.Section{Title: name, Level: 1, Kids: kids}, + } +} + +func prettyJSON(raw json.RawMessage) string { + var buf bytes.Buffer + + if err := json.Indent(&buf, raw, "", " "); err != nil { + return string(raw) + } + + return buf.String() +} diff --git a/internal/mcp/coverage_test.go b/internal/mcp/coverage_test.go index d44d0ad..7f813ac 100644 --- a/internal/mcp/coverage_test.go +++ b/internal/mcp/coverage_test.go @@ -314,7 +314,8 @@ func TestJwtinfoHandler_requestURL(t *testing.T) { RequestValues: map[string]string{"grant_type": "client_credentials"}, }) require.NoError(t, err) - require.Contains(t, out.Output, "JwtInfo") + require.Contains(t, out.Output, `"schemaVersion"`) + require.Contains(t, out.Output, `"jwtinfo"`) } func TestRequestsConfigTemplateHandler_error(t *testing.T) { diff --git a/internal/mcp/tools_exec.go b/internal/mcp/tools_exec.go index 37a40ee..620bb85 100644 --- a/internal/mcp/tools_exec.go +++ b/internal/mcp/tools_exec.go @@ -322,14 +322,21 @@ func executeJwtinfo(ctx context.Context, input jwtinfoInput) (execToolOutput, er } } - output, err := captureOutput(func(w io.Writer) error { - return jwtinfo.PrintTokenInfo(tokenData, w) - }) + return jwtinfoJSONOutput(tokenData) +} + +func jwtinfoJSONOutput(tokenData *jwtinfo.JwtTokenData) (execToolOutput, error) { + result, err := tokenData.BuildResult() if err != nil { return execToolOutput{}, err } - return execToolOutput{Output: output}, nil + payload, err := jwtinfo.EncodeJSON(result) + if err != nil { + return execToolOutput{}, err + } + + return execToolOutput{Output: string(payload)}, nil } func executeGenerateJWKS(ctx context.Context, input generateJWKSInput) (execToolOutput, error) { diff --git a/internal/mcp/tools_exec_test.go b/internal/mcp/tools_exec_test.go index b77205f..076e570 100644 --- a/internal/mcp/tools_exec_test.go +++ b/internal/mcp/tools_exec_test.go @@ -61,7 +61,8 @@ func TestJwtinfoTool_tokenFile(t *testing.T) { "tokenFile": tokenFile, }) require.Empty(t, out["error"]) - require.Contains(t, out["output"], "JwtInfo") + require.Contains(t, out["output"], `"schemaVersion"`) + require.Contains(t, out["output"], `"jwtinfo"`) } func TestRunRequestsTool_invalidConfig(t *testing.T) { diff --git a/internal/view/render.go b/internal/view/render.go index 26a1a62..ccd74f4 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -68,7 +68,13 @@ func (r renderer) renderNode(n Node) error { case Table: return r.renderTable(v) case Code: + if r.styled && v.Lang != "" { + _, err := fmt.Fprint(r.w, style.CodeSyntaxHighlight(v.Lang, v.Body)) + return err + } + _, err := fmt.Fprintln(r.w, v.Body) + return err default: return fmt.Errorf("view: unsupported node %T", n) diff --git a/internal/view/render_test.go b/internal/view/render_test.go index 632be0c..81f617c 100644 --- a/internal/view/render_test.go +++ b/internal/view/render_test.go @@ -53,3 +53,33 @@ func TestRender_ForceColor(t *testing.T) { require.NoError(t, Render(&buf, doc, Options{ForceColor: true})) require.Contains(t, buf.String(), "Certinfo") } + +func TestRender_Code_Plain(t *testing.T) { + t.Parallel() + + doc := Doc{Nodes: []Node{ + Code{Lang: "json", Body: `{"alg":"RS256"}`}, + }} + + var buf bytes.Buffer + require.NoError(t, Render(&buf, doc, Options{Plain: true})) + + got := buf.String() + require.Contains(t, got, `"alg":"RS256"`) + require.NotContains(t, got, "\x1b[") +} + +func TestRender_Code_ForceColor(t *testing.T) { + t.Parallel() + + doc := Doc{Nodes: []Node{ + Code{Lang: "json", Body: `{"alg":"RS256"}`}, + }} + + var buf bytes.Buffer + require.NoError(t, Render(&buf, doc, Options{ForceColor: true})) + + got := buf.String() + require.Contains(t, got, "RS256") + require.Contains(t, got, "\x1b[") +} From 8f2979b91cd05c4fa52f5d73c9eaf4441b9246d3 Mon Sep 17 00:00:00 2001 From: Zeno Belli Date: Sun, 13 Sep 2026 21:33:45 +0200 Subject: [PATCH 2/2] fix(jwtinfo): route JSON progress to stderr, populate refresh Valid, and preserve Code newline Keep stdout exclusively machine-readable JSON when --token-output-file or --refresh is enabled, populate RefreshToken.Valid from RefreshTokenJwt, and ensure Code rendering maintains trailing line boundaries. --- internal/cmd/jwtinfo.go | 16 +++++-- internal/cmd/jwtinfo_test.go | 85 +++++++++++++++++++++++++++++++++ internal/jwtinfo/result.go | 5 ++ internal/jwtinfo/result_test.go | 11 +++-- internal/view/render.go | 8 +++- internal/view/render_test.go | 3 ++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/internal/cmd/jwtinfo.go b/internal/cmd/jwtinfo.go index 8cad9d1..be8bb8b 100644 --- a/internal/cmd/jwtinfo.go +++ b/internal/cmd/jwtinfo.go @@ -6,6 +6,7 @@ package cmd import ( "context" + "fmt" "io" "net/http" "os" @@ -205,6 +206,11 @@ Examples: } out := cmd.OutOrStdout() + statusOut := out + + if jwtinfoFmt == "json" { + statusOut = cmd.ErrOrStderr() + } if jwtinfoFmt == "json" { payload, encErr := jwtinfo.EncodeJSON(result) @@ -227,7 +233,7 @@ Examples: } if tokenOutputFile != "" { - tokenData.WriteTokenToFile(tokenOutputFile, out) + tokenData.WriteTokenToFile(tokenOutputFile, statusOut) } if refresh { @@ -243,7 +249,7 @@ Examples: cancel() }() - cmd.Printf("Starting refresh loop...\n") + fmt.Fprintln(statusOut, "Starting refresh loop...") err := tokenData.RefreshLoop( ctx, @@ -253,12 +259,12 @@ Examples: io.ReadAll, renewThreshold, tokenOutputFile, - cmd.OutOrStdout(), + statusOut, ) if err != nil { - cmd.Printf("Refresh loop exited with error: %s\n", errdisp.FormatCause(err)) + fmt.Fprintf(statusOut, "Refresh loop exited with error: %s\n", errdisp.FormatCause(err)) } else { - cmd.Printf("Refresh loop stopped gracefully.\n") + fmt.Fprintln(statusOut, "Refresh loop stopped gracefully.") } } } else { diff --git a/internal/cmd/jwtinfo_test.go b/internal/cmd/jwtinfo_test.go index 993feb5..9f80314 100644 --- a/internal/cmd/jwtinfo_test.go +++ b/internal/cmd/jwtinfo_test.go @@ -6,7 +6,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "testing" + "time" "github.com/spf13/pflag" "github.com/stretchr/testify/require" @@ -124,6 +126,89 @@ func TestJwtinfoCmd_Success(t *testing.T) { }) } +func TestJwtinfoCmd_JSONStatusRouting_OutputFile(t *testing.T) { + ts := newMockTokenServer() + defer ts.Close() + + resetFlags() + + tmpFile := filepath.Join(t.TempDir(), "out.jwt") + tokenOutputFile = tmpFile + requestURL = ts.URL + requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} + jwtinfoFmt = "json" + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + jwtinfoCmd.SetOut(stdout) + jwtinfoCmd.SetErr(stderr) + jwtinfoCmd.SetContext(context.Background()) + + jwtinfoCmd.Run(jwtinfoCmd, nil) + + gotOut := stdout.String() + require.NotContains(t, gotOut, "\x1b[") + require.NotContains(t, gotOut, "Token persisted to") + + var res jwtinfo.Result + require.NoError(t, json.Unmarshal(stdout.Bytes(), &res)) + require.Equal(t, jwtinfo.ResultSchemaVersion, res.SchemaVersion) + + require.Contains(t, stderr.String(), "Token persisted to") +} + +func TestJwtinfoCmd_JSONStatusRouting_Refresh(t *testing.T) { + ts := newMockTokenServer() + defer ts.Close() + + resetFlags() + + requestURL = ts.URL + requestSteps = []requestValueStep{{kind: "kv", value: "key=val"}} + refresh = true + jwtinfoFmt = "json" + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + jwtinfoCmd.SetOut(stdout) + jwtinfoCmd.SetErr(stderr) + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + jwtinfoCmd.SetContext(ctx) + + jwtinfoCmd.Run(jwtinfoCmd, nil) + + gotOut := stdout.String() + require.NotContains(t, gotOut, "\x1b[") + require.NotContains(t, gotOut, "Starting refresh loop") + require.NotContains(t, gotOut, "Refresh loop") + + var res jwtinfo.Result + require.NoError(t, json.Unmarshal(stdout.Bytes(), &res)) + require.Equal(t, jwtinfo.ResultSchemaVersion, res.SchemaVersion) + + require.Contains(t, stderr.String(), "Starting refresh loop") +} + +func newMockTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2M" + + "jM5MDIyLCJleHAiOjE1MTYyNDkwMjJ9.c2lnbmF0dXJl" + _, _ = w.Write([]byte(`{"access_token": "` + token + `"}`)) + })) +} + func resetFlags() { jwtinfoCmd.Flags().VisitAll(func(f *pflag.Flag) { f.Changed = false diff --git a/internal/jwtinfo/result.go b/internal/jwtinfo/result.go index d11be73..6775c7b 100644 --- a/internal/jwtinfo/result.go +++ b/internal/jwtinfo/result.go @@ -65,6 +65,11 @@ func (jtd *JwtTokenData) BuildResult() (*Result, error) { return nil, err } + if jtd.RefreshTokenJwt != nil { + valid := jtd.RefreshTokenJwt.Valid + sec.Valid = &valid + } + r.RefreshToken = sec } diff --git a/internal/jwtinfo/result_test.go b/internal/jwtinfo/result_test.go index e72932f..ffc412f 100644 --- a/internal/jwtinfo/result_test.go +++ b/internal/jwtinfo/result_test.go @@ -56,15 +56,20 @@ func TestBuildResult_ValidWhenParsed(t *testing.T) { now := time.Now().Unix() exp := now + 3600 jtd := &JwtTokenData{ - AccessTokenHeader: []byte(`{"alg":"RS256","typ":"JWT"}`), - AccessTokenClaims: []byte(fmt.Sprintf(`{"iat":%d,"exp":%d}`, now, exp)), - AccessTokenJwt: &jwt.Token{Valid: true}, + AccessTokenHeader: []byte(`{"alg":"RS256","typ":"JWT"}`), + AccessTokenClaims: []byte(fmt.Sprintf(`{"iat":%d,"exp":%d}`, now, exp)), + AccessTokenJwt: &jwt.Token{Valid: true}, + RefreshTokenHeader: []byte(`{"alg":"HS256","typ":"JWT"}`), + RefreshTokenClaims: []byte(fmt.Sprintf(`{"iat":%d,"exp":%d}`, now, exp)), + RefreshTokenJwt: &jwt.Token{Valid: true}, } result, err := jtd.BuildResult() require.NoError(t, err) require.NotNil(t, result.AccessToken.Valid) require.True(t, *result.AccessToken.Valid) + require.NotNil(t, result.RefreshToken.Valid) + require.True(t, *result.RefreshToken.Valid) } func TestBuildDoc_PlainHasNoANSI(t *testing.T) { diff --git a/internal/view/render.go b/internal/view/render.go index ccd74f4..535e906 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -69,7 +69,13 @@ func (r renderer) renderNode(n Node) error { return r.renderTable(v) case Code: if r.styled && v.Lang != "" { - _, err := fmt.Fprint(r.w, style.CodeSyntaxHighlight(v.Lang, v.Body)) + out := style.CodeSyntaxHighlight(v.Lang, v.Body) + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + + _, err := fmt.Fprint(r.w, out) + return err } diff --git a/internal/view/render_test.go b/internal/view/render_test.go index 81f617c..419c3fd 100644 --- a/internal/view/render_test.go +++ b/internal/view/render_test.go @@ -6,6 +6,7 @@ package view import ( "bytes" + "strings" "testing" "github.com/stretchr/testify/require" @@ -82,4 +83,6 @@ func TestRender_Code_ForceColor(t *testing.T) { got := buf.String() require.Contains(t, got, "RS256") require.Contains(t, got, "\x1b[") + require.True(t, strings.HasSuffix(got, "\n")) + require.False(t, strings.HasSuffix(got, "\n\n")) }