From 17ef4950889bad6d8cf8f6a770322af5c8cd2b4d Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Mon, 9 Feb 2026 13:48:55 +0100 Subject: [PATCH 1/2] Prettify the JSON output and implement configurable listening port --- cmd/authz/pkce.go | 2 +- cmd/authz/user.go | 2 +- cmd/decode.go | 12 ++++--- cmd/organizations.go | 3 +- cmd/profile.go | 3 +- cmd/root.go | 4 +-- cmd/validate/access_token.go | 3 +- cmd/validate/authorization_code.go | 3 +- cmd/validate/device_token.go | 3 +- cmd/validate/refresh_token.go | 3 +- ims/authz_user.go | 10 ++++-- ims/decode.go | 56 ++++++++++++++++++++++++------ ims/profile.go | 4 +-- output/output.go | 30 ++++++++++++++++ 14 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 output/output.go diff --git a/cmd/authz/pkce.go b/cmd/authz/pkce.go index d848fde..7252d7f 100644 --- a/cmd/authz/pkce.go +++ b/cmd/authz/pkce.go @@ -42,7 +42,7 @@ func UserPkceCmd(imsConfig *ims.Config) *cobra.Command { cmd.Flags().StringVarP(&imsConfig.Organization, "organization", "o", "", "IMS Organization.") cmd.Flags().StringSliceVarP(&imsConfig.Scopes, "scopes", "s", []string{""}, "Scopes to request.") cmd.Flags().BoolVarP(&imsConfig.PublicClient, "public", "b", false, "Public client, ignore secret.") - //TODO: cmd.Flags().IntVarP(&imsConfig.Port, "port", "C", 8888, "Local port to be used by the OAuth Client.") + cmd.Flags().IntVarP(&imsConfig.Port, "port", "l", 8888, "Local port to be used by the OAuth Client.") return cmd } diff --git a/cmd/authz/user.go b/cmd/authz/user.go index 2ebdff8..66c39ae 100644 --- a/cmd/authz/user.go +++ b/cmd/authz/user.go @@ -41,7 +41,7 @@ func UserCmd(imsConfig *ims.Config) *cobra.Command { cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "p", "", "IMS client secret.") cmd.Flags().StringVarP(&imsConfig.Organization, "organization", "o", "", "IMS Organization.") cmd.Flags().StringSliceVarP(&imsConfig.Scopes, "scopes", "s", []string{""}, "Scopes to request.") - //TODO: cmd.Flags().IntVarP(&imsConfig.Port, "port", "C", 8888, "Local port to be used by the OAuth Client.") + cmd.Flags().IntVarP(&imsConfig.Port, "port", "l", 8888, "Local port to be used by the OAuth Client.") return cmd } diff --git a/cmd/decode.go b/cmd/decode.go index c3f9632..baa9c12 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -22,18 +22,20 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { Use: "decode", Aliases: []string{"dec"}, Short: "Decode a JWT token.", - Long: "Decode a JWT token.", + Long: "Decode a JWT token and display the header and payload as prettified JSON.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true cmd.SilenceErrors = true - resp, err := imsConfig.DecodeToken() + decoded, err := imsConfig.DecodeToken() if err != nil { return fmt.Errorf("error decoding the token: %v", err) } - for _, part := range resp { - fmt.Println(part) - } + + fmt.Println(decoded.Header) + fmt.Println() + fmt.Println(decoded.Payload) + return nil }, } diff --git a/cmd/organizations.go b/cmd/organizations.go index 433f395..8f87945 100644 --- a/cmd/organizations.go +++ b/cmd/organizations.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ func organizationsCmd(imsConfig *ims.Config) *cobra.Command { if err != nil { return fmt.Errorf("error in get organizations cmd: %v", err) } - fmt.Println(resp) + output.PrintPrettyJSON(resp) return nil }, } diff --git a/cmd/profile.go b/cmd/profile.go index 39e9e74..903f96f 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -31,7 +32,7 @@ func profileCmd(imsConfig *ims.Config) *cobra.Command { if err != nil { return fmt.Errorf("error in get profile cmd: %v", err) } - fmt.Println(resp) + output.PrintPrettyJSON(resp) return nil }, } diff --git a/cmd/root.go b/cmd/root.go index 4e0004e..4916557 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,7 +11,7 @@ package cmd import ( - "io/ioutil" + "io" "log" "github.com/adobe/imscli/ims" @@ -30,7 +30,7 @@ func RootCmd(version string) *cobra.Command { Version: version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if !verbose { - log.SetOutput(ioutil.Discard) + log.SetOutput(io.Discard) } // This call of the initParams will load all env vars, config file and flags. return initParams(cmd, imsConfig, configFile) diff --git a/cmd/validate/access_token.go b/cmd/validate/access_token.go index de0a8ed..8fa7ee2 100644 --- a/cmd/validate/access_token.go +++ b/cmd/validate/access_token.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { if !resp.Valid { return fmt.Errorf("invalid token: %v", resp.Info) } - fmt.Println(resp.Info) + output.PrintPrettyJSON(resp.Info) return nil }, } diff --git a/cmd/validate/authorization_code.go b/cmd/validate/authorization_code.go index 3b0ac64..49e7750 100644 --- a/cmd/validate/authorization_code.go +++ b/cmd/validate/authorization_code.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ func AuthzCodeCmd(imsConfig *ims.Config) *cobra.Command { if !resp.Valid { return fmt.Errorf("invalid token: %v", resp.Info) } - fmt.Println(resp.Info) + output.PrintPrettyJSON(resp.Info) return nil }, } diff --git a/cmd/validate/device_token.go b/cmd/validate/device_token.go index d9d8741..dd50fe3 100644 --- a/cmd/validate/device_token.go +++ b/cmd/validate/device_token.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { if !resp.Valid { return fmt.Errorf("invalid token: %v", resp.Info) } - fmt.Println(resp.Info) + output.PrintPrettyJSON(resp.Info) return nil }, } diff --git a/cmd/validate/refresh_token.go b/cmd/validate/refresh_token.go index 3ca16f3..5c0ab9e 100644 --- a/cmd/validate/refresh_token.go +++ b/cmd/validate/refresh_token.go @@ -14,6 +14,7 @@ import ( "fmt" "github.com/adobe/imscli/ims" + "github.com/adobe/imscli/output" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { if !resp.Valid { return fmt.Errorf("invalid token: %v", resp.Info) } - fmt.Println(resp.Info) + output.PrintPrettyJSON(resp.Info) return nil }, } diff --git a/ims/authz_user.go b/ims/authz_user.go index a013dbf..f70ad8f 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -24,7 +24,7 @@ import ( "github.com/pkg/browser" ) -const port = 8888 +const defaultPort = 8888 /* * Validate that: @@ -66,6 +66,12 @@ func (i Config) AuthorizeUser() (string, error) { return "", fmt.Errorf("invalid parameters for login user: %v", err) } + // Use default port if not specified + port := i.Port + if port == 0 { + port = defaultPort + } + httpClient, err := i.httpClient() if err != nil { return "", fmt.Errorf("error creating the HTTP Client: %v", err) @@ -85,7 +91,7 @@ func (i Config) AuthorizeUser() (string, error) { ClientSecret: i.ClientSecret, Scope: i.Scopes, UsePKCE: i.PKCE, - RedirectURI: "http://localhost:8888", + RedirectURI: fmt.Sprintf("http://localhost:%d", port), OnError: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, `

An error occurred

diff --git a/ims/decode.go b/ims/decode.go index 8bb1ce0..4ab4db2 100644 --- a/ims/decode.go +++ b/ims/decode.go @@ -11,11 +11,20 @@ package ims import ( + "bytes" "encoding/base64" + "encoding/json" "fmt" "strings" ) +// DecodedToken represents the decoded parts of a JWT token. +type DecodedToken struct { + Header string + Payload string + Signature string +} + func (i Config) validateDecodeTokenConfig() error { if i.Token == "" { @@ -24,7 +33,7 @@ func (i Config) validateDecodeTokenConfig() error { return nil } -func (i Config) DecodeToken() ([]string, error) { +func (i Config) DecodeToken() (*DecodedToken, error) { err := i.validateDecodeTokenConfig() if err != nil { return nil, fmt.Errorf("incomplete parameters for token decodification: %v", err) @@ -35,13 +44,40 @@ func (i Config) DecodeToken() ([]string, error) { return nil, fmt.Errorf("the JWT is not composed by 3 parts") } - // i<2 to not decode the signature since it is not encoded - for i:=0; i<2; i++ { - decodedPart, err := base64.RawURLEncoding.DecodeString(parts[i]) - if err != nil { - return nil, fmt.Errorf("error decoding token, part %d: %v", i, err) - } - parts[i] = string(decodedPart) + // Decode header and payload (not signature since it's binary) + decoded := &DecodedToken{ + Signature: parts[2], + } + + // Decode and prettify header + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("error decoding token header: %v", err) + } + decoded.Header, err = prettyJSON(headerBytes) + if err != nil { + return nil, fmt.Errorf("error formatting token header: %v", err) + } + + // Decode and prettify payload + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("error decoding token payload: %v", err) + } + decoded.Payload, err = prettyJSON(payloadBytes) + if err != nil { + return nil, fmt.Errorf("error formatting token payload: %v", err) } - return parts, nil -} \ No newline at end of file + + return decoded, nil +} + +// prettyJSON formats JSON bytes with indentation. +func prettyJSON(data []byte) (string, error) { + var prettyBuf bytes.Buffer + err := json.Indent(&prettyBuf, data, "", " ") + if err != nil { + return "", err + } + return prettyBuf.String(), nil +} diff --git a/ims/profile.go b/ims/profile.go index d21108f..e175a65 100644 --- a/ims/profile.go +++ b/ims/profile.go @@ -145,11 +145,11 @@ func modifyFulfillableData(data string) (string, error) { } defer func() { if _, gzErr := io.Copy(io.Discard, gzipReader); gzErr != nil { - fmt.Errorf("error while consuming the gzip reader: %v", gzErr) + log.Printf("error while consuming the gzip reader: %v", gzErr) } if gzErr := gzipReader.Close(); gzErr != nil { - fmt.Errorf("unable to close gzip reader: %v", gzErr) + log.Printf("unable to close gzip reader: %v", gzErr) } }() diff --git a/output/output.go b/output/output.go new file mode 100644 index 0000000..12d43cf --- /dev/null +++ b/output/output.go @@ -0,0 +1,30 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package output + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// PrintPrettyJSON prints a JSON string with indentation. +// If the input is not valid JSON, it prints the original string. +func PrintPrettyJSON(jsonStr string) { + var prettyBuf bytes.Buffer + err := json.Indent(&prettyBuf, []byte(jsonStr), "", " ") + if err != nil { + // Not valid JSON, print as-is + fmt.Println(jsonStr) + return + } + fmt.Println(prettyBuf.String()) +} From cc64275272a86d2fa40bdad10b45c5b0e97e91a9 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Thu, 12 Feb 2026 16:33:05 +0100 Subject: [PATCH 2/2] Add output tests --- output/output_test.go | 82 ++++++++++++++++++++++++++++++++ output/testdata/empty_string.txt | 1 + output/testdata/non_json.txt | 1 + 3 files changed, 84 insertions(+) create mode 100644 output/output_test.go create mode 100644 output/testdata/empty_string.txt create mode 100644 output/testdata/non_json.txt diff --git a/output/output_test.go b/output/output_test.go new file mode 100644 index 0000000..73005f1 --- /dev/null +++ b/output/output_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. + +package output + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +// captureStdout captures the output of a function that writes to stdout. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = w + + fn() + + w.Close() + os.Stdout = old + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read pipe: %v", err) + } + return buf.String() +} + +// loadExpected reads the expected output from a golden file in testdata/. +func loadExpected(t *testing.T, filename string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", filename)) + if err != nil { + t.Fatalf("failed to read golden file %s: %v", filename, err) + } + return string(data) +} + +func TestPrintPrettyJSON(t *testing.T) { + tests := []struct { + name string + input string + file string + }{ + {name: "compact object is prettified", input: `{"name":"John","age":30}`, file: "compact_object.json"}, + {name: "already indented JSON is normalized", input: "{\n \"key\": \"value\"\n}", file: "already_indented.json"}, + {name: "compact array is prettified", input: `[{"id":1},{"id":2}]`, file: "compact_array.json"}, + {name: "empty object", input: `{}`, file: "empty_object.json"}, + {name: "empty array", input: `[]`, file: "empty_array.json"}, + {name: "nested objects", input: `{"a":{"b":{"c":1}}}`, file: "nested_objects.json"}, + {name: "non-JSON is printed as-is", input: "this is not JSON", file: "non_json.txt"}, + {name: "empty string is printed as-is", input: "", file: "empty_string.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expected := loadExpected(t, tt.file) + + got := captureStdout(t, func() { + PrintPrettyJSON(tt.input) + }) + + if got != expected { + t.Errorf("PrintPrettyJSON(%q)\ngot:\n%s\nexpected output is in testdata/%s", tt.input, got, tt.file) + } + }) + } +} diff --git a/output/testdata/empty_string.txt b/output/testdata/empty_string.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/output/testdata/empty_string.txt @@ -0,0 +1 @@ + diff --git a/output/testdata/non_json.txt b/output/testdata/non_json.txt new file mode 100644 index 0000000..7f8c66c --- /dev/null +++ b/output/testdata/non_json.txt @@ -0,0 +1 @@ +this is not JSON