diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4db9d72 --- /dev/null +++ b/TODO.md @@ -0,0 +1,55 @@ +# TODO + +## Slack Webhook Notifications + +Add Slack notifications on CI failures. This would post to a team channel when +the CI pipeline, govulncheck, or release workflows fail. + +## Comprehensive Test Coverage + +Current coverage is ~46%. The tested code covers pure functions (validation, +decoding, client construction, profile parsing) and fuzz tests. The untested +code is mostly thin wrappers that call `ims-go` through `newIMSClient()`. + +### What we tried + +**httptest approach**: Create a local HTTP server, point `Config.URL` at it, and +let the real code path execute end-to-end. This works mechanically — the tests +pass — but the assertions are shallow. The fake server returns canned responses +regardless of input, so we're testing that our code passes arguments through and +wraps results, not that it does anything meaningful. The tests are also tightly +coupled to `ims-go` implementation details (endpoint paths like +`/ims/validate_token/v1`), meaning they break if the library changes internals +even though our code is fine. + +### What the untested code actually looks like + +Each public function follows the same pattern: + +``` +validate config → create client → call ims-go → wrap result +``` + +The validation is already tested. The client creation is already tested. What +remains is ~5 lines of glue per function where httptest gives line coverage but +not meaningful assertions. + +### Open question + +Is there an approach that tests our behavior rather than `ims-go`'s wire format? +Options to explore: + +- Accept the shallow httptest coverage as "good enough" for glue code +- Focus testing effort on new features where logic is non-trivial +- Explore contract testing if `ims-go` publishes response schemas +- **Mock IMS server**: Build a standalone fake IMS service that understands the + real API contract (paths, params, auth headers) and returns realistic responses. + Run the CLI binary against it as an integration test. Tests real behavior + end-to-end including flag parsing, config loading, and output formatting — + not just the `ims/` package. Tradeoff: the mock needs maintaining as the API + evolves, but catches issues that unit tests never will. +- **Real IMS integration tests**: Run a subset of tests against the actual IMS + API using a dedicated test client. Gate behind a build tag + (`//go:build integration`) or env var so they don't run in CI by default. + Tradeoff: requires credentials, is slow, can flake on network issues — but + is the only way to catch real API drift or behavioral changes. diff --git a/cmd/admin/profile.go b/cmd/admin/profile.go index 9904ad8..9db3bcb 100644 --- a/cmd/admin/profile.go +++ b/cmd/admin/profile.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package admin implements the admin subcommands (profile, organizations). package admin import ( diff --git a/cmd/authz/user.go b/cmd/authz/user.go index 471982e..4daa39f 100644 --- a/cmd/authz/user.go +++ b/cmd/authz/user.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package authz implements the authorize subcommands (user, pkce, service, jwt, client). package authz import ( diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..5cd2447 --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,55 @@ +// 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 cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +func completionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion script.", + Long: `Generate a shell completion script for the specified shell. + + # Bash + source <(imscli completion bash) + + # Zsh (add to ~/.zshrc or run once) + imscli completion zsh > "${fpath[1]}/_imscli" + + # Fish + imscli completion fish | source + + # PowerShell + imscli completion powershell | Out-String | Invoke-Expression +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletionV2(os.Stdout, true) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletion(os.Stdout) + } + return nil + }, + } + return cmd +} diff --git a/cmd/dcr.go b/cmd/dcr.go index 7ca9116..5bde725 100644 --- a/cmd/dcr.go +++ b/cmd/dcr.go @@ -1,8 +1,19 @@ +// 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 cmd import ( "fmt" + "github.com/adobe/imscli/cmd/pretty" "github.com/adobe/imscli/ims" "github.com/spf13/cobra" ) @@ -30,11 +41,10 @@ func registerCmd(imsConfig *ims.Config) *cobra.Command { resp, err := imsConfig.Register() if err != nil { - return fmt.Errorf("error during client registration: %v", err) + return fmt.Errorf("error during client registration: %w", err) } - fmt.Printf("Status Code: %d\n", resp.StatusCode) - fmt.Println(resp.Body) + fmt.Println(pretty.JSON(resp)) return nil }, } diff --git a/cmd/decode.go b/cmd/decode.go index a5a0a3b..3ba559a 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -11,7 +11,10 @@ package cmd import ( + "encoding/json" "fmt" + "os" + "time" "github.com/adobe/imscli/cmd/pretty" "github.com/adobe/imscli/ims" @@ -27,7 +30,6 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - decoded, err := imsConfig.DecodeToken() if err != nil { return fmt.Errorf("error decoding the token: %w", err) @@ -36,6 +38,12 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { output := fmt.Sprintf(`{"header":%s,"payload":%s}`, decoded.Header, decoded.Payload) fmt.Println(pretty.JSON(output)) + // When verbose, show human-readable token expiration on stderr + // so it doesn't pollute the JSON output on stdout. + if imsConfig.Verbose { + printTokenExpiration(decoded.Payload) + } + return nil }, } @@ -44,3 +52,28 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { return cmd } + +// printTokenExpiration parses the "exp" claim from a JWT payload and prints +// a human-readable expiration message to stderr. +func printTokenExpiration(payload string) { + var claims map[string]interface{} + if err := json.Unmarshal([]byte(payload), &claims); err != nil { + return + } + + exp, ok := claims["exp"].(float64) + if !ok { + return + } + + expTime := time.Unix(int64(exp), 0).UTC() + now := time.Now().UTC() + + if now.After(expTime) { + fmt.Fprintf(os.Stderr, "\nToken expired: %s (%s ago)\n", + expTime.Format(time.RFC3339), now.Sub(expTime).Truncate(time.Second)) + } else { + fmt.Fprintf(os.Stderr, "\nToken expires: %s (in %s)\n", + expTime.Format(time.RFC3339), expTime.Sub(now).Truncate(time.Second)) + } +} diff --git a/cmd/invalidate/invalidate.go b/cmd/invalidate/invalidate.go index 4a5f82a..32db000 100644 --- a/cmd/invalidate/invalidate.go +++ b/cmd/invalidate/invalidate.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package invalidate implements the invalidate subcommands for token invalidation. package invalidate import ( diff --git a/cmd/pretty/json.go b/cmd/pretty/json.go index 00fb1ee..c6ccaa0 100644 --- a/cmd/pretty/json.go +++ b/cmd/pretty/json.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package pretty provides output formatting helpers for the CLI. package pretty import ( diff --git a/cmd/pretty/json_test.go b/cmd/pretty/json_test.go index 676ceab..e534798 100644 --- a/cmd/pretty/json_test.go +++ b/cmd/pretty/json_test.go @@ -11,10 +11,12 @@ package pretty import ( + "math/rand" "os" "path/filepath" "strings" "testing" + "time" ) func loadExpected(t *testing.T, filename string) string { @@ -55,3 +57,56 @@ func TestJSON(t *testing.T) { }) } } + +// randomString generates a string of the given length with arbitrary bytes, +// including invalid UTF-8, control characters, and JSON-significant characters. +// Used by the fuzz tests below to verify that JSON never panics. +func randomString(rng *rand.Rand, length int) string { + b := make([]byte, length) + for i := range b { + b[i] = byte(rng.Intn(256)) + } + return string(b) +} + +// TestFuzzJSON generates random inputs for 10 seconds to verify that JSON +// never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/ +func TestFuzzJSON(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + input := randomString(rng, rng.Intn(1024)) + _ = JSON(input) + iterations++ + } + } +} + +// FuzzJSON is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzJSON -fuzztime=60s ./cmd/pretty/ +func FuzzJSON(f *testing.F) { + f.Add(`{}`) + f.Add(`[]`) + f.Add(`{"a":1}`) + f.Add(`"plain string"`) + f.Add(`null`) + f.Add(`not json`) + f.Add(``) + + f.Fuzz(func(t *testing.T, input string) { + _ = JSON(input) + }) +} diff --git a/cmd/root.go b/cmd/root.go index d394265..f160f29 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,9 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package cmd defines the Cobra command tree for the imscli CLI. +// Each subcommand is a function returning *cobra.Command that binds flags +// to the shared ims.Config struct. package cmd import ( @@ -34,6 +37,7 @@ func RootCmd(version string) *cobra.Command { log.SetOutput(io.Discard) } // This call of the initParams will load all env vars, config file and flags. + imsConfig.Verbose = verbose return initParams(cmd, imsConfig, configFile) }, } @@ -58,6 +62,7 @@ func RootCmd(version string) *cobra.Command { refreshCmd(imsConfig), adminCmd(imsConfig), dcrCmd(imsConfig), + completionCmd(), ) return cmd } diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go index 10e7a18..72f7947 100644 --- a/cmd/validate/validate.go +++ b/cmd/validate/validate.go @@ -8,6 +8,7 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package validate implements the validate subcommands for token validation. package validate import ( diff --git a/ims/admin_organizations.go b/ims/admin_organizations.go index a239292..0fed4d6 100644 --- a/ims/admin_organizations.go +++ b/ims/admin_organizations.go @@ -30,6 +30,8 @@ func (i Config) validateGetAdminOrganizationsConfig() error { return fmt.Errorf("missing service token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.Guid == "": diff --git a/ims/admin_profile.go b/ims/admin_profile.go index f69da7c..0de63a5 100644 --- a/ims/admin_profile.go +++ b/ims/admin_profile.go @@ -29,6 +29,8 @@ func (i Config) validateGetAdminProfileConfig() error { return fmt.Errorf("missing service token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.Guid == "": diff --git a/ims/authz_client.go b/ims/authz_client.go index a4f26c9..4699798 100644 --- a/ims/authz_client.go +++ b/ims/authz_client.go @@ -20,6 +20,8 @@ func (i Config) validateAuthorizeClientCredentialsConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": @@ -31,7 +33,7 @@ func (i Config) validateAuthorizeClientCredentialsConfig() error { } } -// AuthorizeClientCredentials : Client Credentials OAuth flow +// AuthorizeClientCredentials performs the Client Credentials OAuth flow. func (i Config) AuthorizeClientCredentials() (string, error) { if err := i.validateAuthorizeClientCredentialsConfig(); err != nil { @@ -50,7 +52,7 @@ func (i Config) AuthorizeClientCredentials() (string, error) { GrantType: "client_credentials", }) if err != nil { - return "", fmt.Errorf("request token: %w", err) + return "", fmt.Errorf("error requesting token: %w", err) } return r.AccessToken, nil diff --git a/ims/authz_service.go b/ims/authz_service.go index 3cc24db..f56df7a 100644 --- a/ims/authz_service.go +++ b/ims/authz_service.go @@ -20,6 +20,8 @@ func (i Config) validateAuthorizeServiceConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": @@ -31,7 +33,7 @@ func (i Config) validateAuthorizeServiceConfig() error { } } -// AuthorizeService : Login for the service to service IMS flow +// AuthorizeService performs the service-to-service IMS authorization flow. func (i Config) AuthorizeService() (string, error) { if err := i.validateAuthorizeServiceConfig(); err != nil { @@ -49,7 +51,7 @@ func (i Config) AuthorizeService() (string, error) { Code: i.AuthorizationCode, }) if err != nil { - return "", fmt.Errorf("request token: %w", err) + return "", fmt.Errorf("error requesting token: %w", err) } return r.AccessToken, nil diff --git a/ims/authz_user.go b/ims/authz_user.go index 452e946..e967b7f 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -24,11 +24,19 @@ import ( "github.com/pkg/browser" ) -// Validate that: -// - the ims.Config struct has the necessary parameters for AuthorizeUser -// - the provided environment exists -func (i Config) validateAuthorizeUserConfig() error { +const ( + // authTimeout is how long the CLI waits for the user to complete the browser-based + // OAuth flow before giving up. + authTimeout = 5 * time.Minute + + // shutdownTimeout is the grace period for the local HTTP server to finish + // serving in-flight requests during shutdown. + shutdownTimeout = 10 * time.Second +) +// validateAuthorizeUserConfig checks that the configuration has valid +// parameters for user authorization. +func (i Config) validateAuthorizeUserConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") @@ -143,7 +151,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { log.Println("The IMS HTTP handler returned a message.") case serr = <-serveCh: log.Println("The local server stopped unexpectedly.") - case <-time.After(time.Minute * 5): + case <-time.After(authTimeout): fmt.Fprintf(os.Stderr, "Timeout reached waiting for the user to finish the authentication ...\n") serr = fmt.Errorf("user timed out") } @@ -160,7 +168,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { } }() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() if err = server.Shutdown(shutdownCtx); err != nil { diff --git a/ims/client.go b/ims/client.go index 5ab17f8..7cd1d97 100644 --- a/ims/client.go +++ b/ims/client.go @@ -1,3 +1,13 @@ +// Copyright 2020 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 ims import ( @@ -8,13 +18,13 @@ import ( "time" ) -// Create a http client based on the received configuration. +// httpClient creates an HTTP client based on the received configuration. func (i Config) httpClient() (*http.Client, error) { if i.ProxyURL != "" { p, err := url.Parse(i.ProxyURL) if err != nil { - return nil, fmt.Errorf("proxy provided but its URL is malformed") + return nil, fmt.Errorf("proxy URL is malformed: %w", err) } t := http.DefaultTransport.(*http.Transport).Clone() t.Proxy = http.ProxyURL(p) diff --git a/ims/config.go b/ims/config.go index 8500eca..2f804be 100644 --- a/ims/config.go +++ b/ims/config.go @@ -8,6 +8,10 @@ // OF ANY KIND, either express or implied. See the License for the specific language // governing permissions and limitations under the License. +// Package ims implements the core business logic for interacting with the +// Adobe Identity Management System (IMS) API. It provides functions for +// authorization flows, token management, profile retrieval, and client +// registration. package ims import ( @@ -17,6 +21,8 @@ import ( "github.com/adobe/ims-go/ims" ) +// Config holds all parameters needed to interact with the IMS API. +// Fields are populated from CLI flags, environment variables, or a config file. type Config struct { URL string ClientID string @@ -42,6 +48,7 @@ type Config struct { Token string Port int FullOutput bool + Verbose bool Guid string AuthSrc string DecodeFulfillableData bool @@ -49,13 +56,14 @@ type Config struct { RedirectURIs []string } -// Access token information +// TokenInfo holds the response data from token-related IMS API calls. type TokenInfo struct { AccessToken string Valid bool Info string } +// RefreshInfo extends TokenInfo with the new refresh token returned by a token refresh. type RefreshInfo struct { TokenInfo RefreshToken string diff --git a/ims/config_test.go b/ims/config_test.go index c628844..5cbcab0 100644 --- a/ims/config_test.go +++ b/ims/config_test.go @@ -11,7 +11,11 @@ package ims import ( + "encoding/base64" + "math/rand" + "net/http" "testing" + "time" ) func TestValidateDecodeTokenConfig(t *testing.T) { @@ -393,3 +397,254 @@ func searchString(s, substr string) bool { } return false } + +func TestDecodeToken(t *testing.T) { + // Build a valid JWT with known header and payload. + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"1234567890"}`)) + validJWT := header + "." + payload + ".signature" + + tests := []struct { + name string + token string + wantHeader string + wantPayload string + wantErr string + }{ + {name: "valid JWT", token: validJWT, wantHeader: `{"alg":"HS256"}`, wantPayload: `{"sub":"1234567890"}`}, + {name: "empty token", token: "", wantErr: "missing token parameter"}, + {name: "no dots", token: "nodots", wantErr: "not composed by 3 parts"}, + {name: "one dot", token: "a.b", wantErr: "not composed by 3 parts"}, + {name: "four parts", token: "a.b.c.d", wantErr: "not composed by 3 parts"}, + {name: "invalid base64 header", token: "!!!." + payload + ".sig", wantErr: "error decoding token header"}, + {name: "invalid base64 payload", token: header + ".!!!.sig", wantErr: "error decoding token payload"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Config{Token: tt.token} + result, err := c.DecodeToken() + assertError(t, err, tt.wantErr) + if err == nil { + if result.Header != tt.wantHeader { + t.Errorf("Header = %q, want %q", result.Header, tt.wantHeader) + } + if result.Payload != tt.wantPayload { + t.Errorf("Payload = %q, want %q", result.Payload, tt.wantPayload) + } + } + }) + } +} + +func TestHttpClient(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + wantProxy bool + wantInsecure bool + }{ + { + name: "default client", + config: Config{Timeout: 30}, + }, + { + name: "with proxy", + config: Config{Timeout: 30, ProxyURL: "http://proxy.example.com:8080"}, + wantProxy: true, + }, + { + name: "with proxy and ignore TLS", + config: Config{Timeout: 30, ProxyURL: "http://proxy.example.com:8080", ProxyIgnoreTLS: true}, + wantProxy: true, + wantInsecure: true, + }, + { + name: "malformed proxy URL", + config: Config{ProxyURL: "://bad"}, + wantErr: "malformed", + }, + { + name: "timeout is respected", + config: Config{Timeout: 60}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := tt.config.httpClient() + assertError(t, err, tt.wantErr) + if err != nil { + return + } + expectedTimeout := time.Duration(tt.config.Timeout) * time.Second + if client.Timeout != expectedTimeout { + t.Errorf("Timeout = %v, want %v", client.Timeout, expectedTimeout) + } + if tt.wantProxy { + transport, ok := client.Transport.(*http.Transport) + if !ok || transport.Proxy == nil { + t.Error("expected proxy to be configured") + } + if tt.wantInsecure { + if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify { + t.Error("expected InsecureSkipVerify to be true") + } + } + } + }) + } +} + +func TestNewIMSClient(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid config", config: Config{URL: "https://ims.example.com", Timeout: 30}}, + {name: "malformed proxy", config: Config{URL: "https://ims.example.com", ProxyURL: "://bad"}, wantErr: "malformed"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.config.newIMSClient() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestDecodeProfile(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {name: "simple profile", input: `{"name":"John","email":"john@example.com"}`}, + {name: "profile with unrelated fulfillable_data", input: `{"serviceCode":"other","fulfillable_data":"test"}`}, + {name: "invalid JSON", input: `not json`, wantErr: "error parsing profile JSON"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodeProfile([]byte(tt.input)) + assertError(t, err, tt.wantErr) + }) + } +} + +func TestFindFulfillableData(t *testing.T) { + // Verify that findFulfillableData doesn't panic on various data structures. + tests := []struct { + name string + input interface{} + }{ + {name: "nil", input: nil}, + {name: "string", input: "hello"}, + {name: "number", input: 42.0}, + {name: "empty map", input: map[string]interface{}{}}, + {name: "empty slice", input: []interface{}{}}, + {name: "nested map", input: map[string]interface{}{"a": map[string]interface{}{"b": 1}}}, + {name: "fulfillable_data with wrong serviceCode", input: map[string]interface{}{"serviceCode": "unknown", "fulfillable_data": "test"}}, + {name: "fulfillable_data non-string value", input: map[string]interface{}{"serviceCode": "dma_media_library", "fulfillable_data": 123}}, + {name: "nested array", input: []interface{}{map[string]interface{}{"key": "value"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Should not panic. + findFulfillableData(tt.input) + }) + } +} + +// randomString generates a string of the given length with arbitrary bytes. +func randomString(rng *rand.Rand, length int) string { + b := make([]byte, length) + for i := range b { + b[i] = byte(rng.Intn(256)) + } + return string(b) +} + +// TestFuzzValidateURL generates random inputs for 10 seconds to verify that +// validateURL never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/ +func TestFuzzValidateURL(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + input := randomString(rng, rng.Intn(512)) + _ = validateURL(input) + iterations++ + } + } +} + +// FuzzValidateURL is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzValidateURL -fuzztime=60s ./ims/ +func FuzzValidateURL(f *testing.F) { + f.Add("https://example.com") + f.Add("http://localhost:8080") + f.Add("") + f.Add("not-a-url") + f.Add("://missing-scheme.com") + f.Add("https://") + + f.Fuzz(func(t *testing.T, u string) { + _ = validateURL(u) + }) +} + +// TestFuzzDecodeToken generates random inputs for 10 seconds to verify that +// DecodeToken never panics regardless of input. Runs in parallel with other tests. +// +// For deeper exploration, use Go's built-in fuzz engine: +// +// go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/ +func TestFuzzDecodeToken(t *testing.T) { + t.Parallel() + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + deadline := time.After(10 * time.Second) + iterations := 0 + + for { + select { + case <-deadline: + t.Logf("fuzz: %d iterations without panic", iterations) + return + default: + // Generate random JWT-like strings (three dot-separated parts) + input := randomString(rng, rng.Intn(128)) + "." + + randomString(rng, rng.Intn(256)) + "." + + randomString(rng, rng.Intn(128)) + c := Config{Token: input} + _, _ = c.DecodeToken() + iterations++ + } + } +} + +// FuzzDecodeToken is a standard Go fuzz target for deeper exploration. +// Run manually: go test -fuzz=FuzzDecodeToken -fuzztime=60s ./ims/ +func FuzzDecodeToken(f *testing.F) { + f.Add("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature") + f.Add("a.b.c") + f.Add("...") + f.Add("") + f.Add("no-dots-at-all") + + f.Fuzz(func(t *testing.T, token string) { + c := Config{Token: token} + _, _ = c.DecodeToken() + }) +} diff --git a/ims/decode.go b/ims/decode.go index b961474..eb1b270 100644 --- a/ims/decode.go +++ b/ims/decode.go @@ -30,6 +30,7 @@ func (i Config) validateDecodeTokenConfig() error { return nil } +// DecodeToken decodes a JWT token into its header and payload parts. func (i Config) DecodeToken() (*DecodedToken, error) { err := i.validateDecodeTokenConfig() if err != nil { diff --git a/ims/exchange.go b/ims/exchange.go index c1405d4..b686552 100644 --- a/ims/exchange.go +++ b/ims/exchange.go @@ -20,6 +20,8 @@ func (i Config) validateClusterExchangeConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": diff --git a/ims/invalidate.go b/ims/invalidate.go index eda2112..509da9b 100644 --- a/ims/invalidate.go +++ b/ims/invalidate.go @@ -17,7 +17,7 @@ import ( "github.com/adobe/ims-go/ims" ) -// Invalidate a token. +// validateInvalidateTokenConfig checks that the configuration has valid parameters for token invalidation. func (i Config) validateInvalidateTokenConfig() error { switch { @@ -25,6 +25,8 @@ func (i Config) validateInvalidateTokenConfig() error { return fmt.Errorf("missing clientID parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.AccessToken != "": log.Println("access token will be invalidated") return nil @@ -45,7 +47,7 @@ func (i Config) validateInvalidateTokenConfig() error { } } -// InvalidateToken Invalidates the token provided in the configuration using the IMS API. +// InvalidateToken invalidates the token provided in the configuration using the IMS API. func (i Config) InvalidateToken() error { // Perform parameter validation err := i.validateInvalidateTokenConfig() @@ -60,7 +62,7 @@ func (i Config) InvalidateToken() error { token, tokenType, err := i.resolveToken() if err != nil { - return fmt.Errorf("unexpected error, broken parameter validation") + return fmt.Errorf("unexpected error resolving token: %w", err) } err = c.InvalidateToken(&ims.InvalidateTokenRequest{ diff --git a/ims/jwt_exchange.go b/ims/jwt_exchange.go index 42a91f4..28ee38d 100644 --- a/ims/jwt_exchange.go +++ b/ims/jwt_exchange.go @@ -19,8 +19,37 @@ import ( "github.com/adobe/ims-go/ims" ) +// jwtExpiration is the lifetime of JWT assertions used for the exchange flow. +const jwtExpiration = 30 * time.Minute + +func (i Config) validateAuthorizeJWTExchangeConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") + case i.ClientID == "": + return fmt.Errorf("missing client ID parameter") + case i.ClientSecret == "": + return fmt.Errorf("missing client secret parameter") + case i.PrivateKeyPath == "": + return fmt.Errorf("missing private key path parameter") + case i.Organization == "": + return fmt.Errorf("missing organization parameter") + case i.Account == "": + return fmt.Errorf("missing account parameter") + default: + return nil + } +} + +// AuthorizeJWTExchange performs the JWT Bearer exchange flow. func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { + if err := i.validateAuthorizeJWTExchangeConfig(); err != nil { + return TokenInfo{}, fmt.Errorf("invalid parameters for JWT exchange: %w", err) + } + c, err := i.newIMSClient() if err != nil { return TokenInfo{}, fmt.Errorf("error creating the IMS client: %w", err) @@ -28,7 +57,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { key, err := os.ReadFile(i.PrivateKeyPath) if err != nil { - return TokenInfo{}, fmt.Errorf("error read private key file: %s, %w", i.PrivateKeyPath, err) + return TokenInfo{}, fmt.Errorf("error reading private key file %s: %w", i.PrivateKeyPath, err) } defer func() { for i := range key { @@ -48,7 +77,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { r, err := c.ExchangeJWT(&ims.ExchangeJWTRequest{ PrivateKey: key, - Expiration: time.Now().Add(time.Minute * 30), + Expiration: time.Now().Add(jwtExpiration), Issuer: i.Organization, Subject: i.Account, ClientID: i.ClientID, @@ -56,7 +85,7 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { Claims: claims, }) if err != nil { - return TokenInfo{}, fmt.Errorf("exchange JWT: %w", err) + return TokenInfo{}, fmt.Errorf("error exchanging JWT: %w", err) } return TokenInfo{ diff --git a/ims/organizations.go b/ims/organizations.go index c9fadc4..0ec21cd 100644 --- a/ims/organizations.go +++ b/ims/organizations.go @@ -30,6 +30,8 @@ func (i Config) validateGetOrganizationsConfig() error { return fmt.Errorf("missing access token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") default: log.Println("all needed parameters verified not empty") } diff --git a/ims/profile.go b/ims/profile.go index 28112f4..9549594 100644 --- a/ims/profile.go +++ b/ims/profile.go @@ -35,6 +35,8 @@ func (i Config) validateGetProfileConfig() error { return fmt.Errorf("missing access token parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") default: log.Println("all needed parameters verified not empty") } @@ -91,16 +93,22 @@ func decodeProfile(profile []byte) (string, error) { return string(modifiedJson), nil } +// fulfillableServiceCodes lists the service codes whose fulfillable_data field +// contains a base64+gzip-encoded payload that can be decoded. +var fulfillableServiceCodes = map[string]bool{ + "dma_media_library": true, + "dma_aem_cloud": true, + "dma_aem_contenthub": true, + "dx_genstudio": true, +} + func findFulfillableData(data interface{}) { switch data := data.(type) { case map[string]interface{}: for key, value := range data { if key == "fulfillable_data" { serviceCode, ok := data["serviceCode"].(string) - if ok && (serviceCode == "dma_media_library" || - serviceCode == "dma_aem_cloud" || - serviceCode == "dma_aem_contenthub" || - serviceCode == "dx_genstudio") { + if ok && fulfillableServiceCodes[serviceCode] { strValue, ok := value.(string) if !ok { diff --git a/ims/refresh.go b/ims/refresh.go index 82ce08e..389b38a 100644 --- a/ims/refresh.go +++ b/ims/refresh.go @@ -20,6 +20,8 @@ func (i Config) validateRefreshConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientID == "": return fmt.Errorf("missing client ID parameter") case i.ClientSecret == "": diff --git a/ims/register.go b/ims/register.go index 1590231..9be70a5 100644 --- a/ims/register.go +++ b/ims/register.go @@ -1,22 +1,29 @@ +// 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 ims import ( + "encoding/json" "fmt" "io" "net/http" "strings" ) -// RegisterResponse contains the response from DCR registration -type RegisterResponse struct { - StatusCode int - Body string -} - func (i Config) validateRegisterConfig() error { switch { case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.ClientName == "": return fmt.Errorf("missing client name parameter") case len(i.RedirectURIs) == 0: @@ -26,48 +33,48 @@ func (i Config) validateRegisterConfig() error { } } -// Register performs Dynamic Client Registration -func (i Config) Register() (RegisterResponse, error) { +// Register performs Dynamic Client Registration. +func (i Config) Register() (string, error) { if err := i.validateRegisterConfig(); err != nil { - return RegisterResponse{}, fmt.Errorf("invalid parameters for client registration: %v", err) + return "", fmt.Errorf("invalid parameters for client registration: %w", err) } - // Build redirect URIs JSON array - redirectURIsJSON := "[" - for idx, uri := range i.RedirectURIs { - if idx > 0 { - redirectURIsJSON += "," - } - redirectURIsJSON += fmt.Sprintf(`"%s"`, uri) + // Build the request payload using json.Marshal for proper escaping. + payload, err := json.Marshal(map[string]interface{}{ + "client_name": i.ClientName, + "redirect_uris": i.RedirectURIs, + }) + if err != nil { + return "", fmt.Errorf("error building registration payload: %w", err) } - redirectURIsJSON += "]" - - payload := strings.NewReader(fmt.Sprintf(`{ - "client_name": "%s", - "redirect_uris": %s -}`, i.ClientName, redirectURIsJSON)) endpoint := strings.TrimRight(i.URL, "/") + "/ims/register" - req, err := http.NewRequest("POST", endpoint, payload) + req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(payload))) if err != nil { - return RegisterResponse{}, fmt.Errorf("error creating request: %v", err) + return "", fmt.Errorf("error creating request: %w", err) } req.Header.Add("content-type", "application/json") - res, err := http.DefaultClient.Do(req) + httpClient, err := i.httpClient() if err != nil { - return RegisterResponse{}, fmt.Errorf("error making registration request: %v", err) + return "", fmt.Errorf("error creating the HTTP client: %w", err) + } + + res, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("error making registration request: %w", err) } defer func() { _ = res.Body.Close() }() - body, err := io.ReadAll(res.Body) + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) // 1 MB max if err != nil { - return RegisterResponse{}, fmt.Errorf("error reading response body: %v", err) + return "", fmt.Errorf("error reading response body: %w", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", fmt.Errorf("registration failed with status %d: %s", res.StatusCode, string(body)) } - return RegisterResponse{ - StatusCode: res.StatusCode, - Body: string(body), - }, nil + return string(body), nil } diff --git a/ims/validate.go b/ims/validate.go index 3495900..ca47cbb 100644 --- a/ims/validate.go +++ b/ims/validate.go @@ -17,10 +17,8 @@ import ( "github.com/adobe/ims-go/ims" ) -// Validate that the config includes: -// - One clientID -// - One token to validate - +// validateValidateTokenConfig checks that the configuration has valid +// parameters for token validation. func (i Config) validateValidateTokenConfig() error { switch { @@ -28,6 +26,8 @@ func (i Config) validateValidateTokenConfig() error { return fmt.Errorf("missing clientID parameter") case i.URL == "": return fmt.Errorf("missing IMS base URL parameter") + case !validateURL(i.URL): + return fmt.Errorf("invalid IMS base URL parameter") case i.AccessToken != "": log.Println("access token will be validated") return nil @@ -45,8 +45,8 @@ func (i Config) validateValidateTokenConfig() error { } } -// ValidateToken Validates the token provided in the configuration using the IMS API. -// Return the endpoint response or an error. +// ValidateToken validates the token provided in the configuration using the IMS API. +// It returns the endpoint response or an error. func (i Config) ValidateToken() (TokenInfo, error) { // Perform parameter validation err := i.validateValidateTokenConfig() @@ -61,7 +61,7 @@ func (i Config) ValidateToken() (TokenInfo, error) { token, tokenType, err := i.resolveToken() if err != nil { - return TokenInfo{}, fmt.Errorf("unexpected error, broken validation") + return TokenInfo{}, fmt.Errorf("unexpected error resolving token: %w", err) } r, err := c.ValidateToken(&ims.ValidateTokenRequest{