From 492077268f2f98ce6f46fbbd98834ee6c4c5428b Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 12:08:17 +0100 Subject: [PATCH 1/8] Extract newIMSClient() helper to deduplicate 13 call sites Add Config.newIMSClient() that wraps httpClient() + ims.NewClient() into a single call. Replaces identical 8-line boilerplate across all 13 IMS operation methods. --- CLAUDE.md | 17 +---------------- ims/admin_organizations.go | 12 ++---------- ims/admin_profile.go | 12 ++---------- ims/authz_client.go | 12 ++---------- ims/authz_service.go | 12 ++---------- ims/authz_user.go | 12 ++---------- ims/config.go | 11 +++++++++++ ims/exchange.go | 12 ++---------- ims/invalidate.go | 12 ++---------- ims/jwt_exchange.go | 12 ++---------- ims/organizations.go | 12 ++---------- ims/profile.go | 12 ++---------- ims/refresh.go | 12 ++---------- ims/validate.go | 12 ++---------- 14 files changed, 36 insertions(+), 136 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ef39ad6..491c527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,22 +46,6 @@ return parsedURL.Scheme != "" && parsedURL.Host != "" ## 3. Structural Improvements -### 3.1 Extract IMS client creation into a helper - -Every method in the `ims` package repeats: - -```go -httpClient, err := i.httpClient() -// ... -c, err := ims.NewClient(&ims.ClientConfig{URL: i.URL, Client: httpClient}) -``` - -This 8-line boilerplate appears in ~10 files. Extract into: - -```go -func (i Config) newIMSClient() (*ims.Client, error) { ... } -``` - ### 3.2 Deduplicate validate subcommands All four files in `cmd/validate/` have identical `RunE` logic — only the flag binding @@ -231,6 +215,7 @@ The following items have been implemented and merged: - **N3** Fix "decodification" → "decoding" (#69) - **2.1** Rename `ProfileApiVersion` → `ProfileAPIVersion`, `OrgsApiVersion` → `OrgsAPIVersion` (#70) - **2.5** Replace C-style `/* */` block comments with `//` line comments (#70) +- **3.1** Extract `newIMSClient()` helper, deduplicate 13 call sites (#70) ### Skipped (not applicable) diff --git a/ims/admin_organizations.go b/ims/admin_organizations.go index 0f38491..a239292 100644 --- a/ims/admin_organizations.go +++ b/ims/admin_organizations.go @@ -51,17 +51,9 @@ func (i Config) GetAdminOrganizations() (string, error) { return "", fmt.Errorf("invalid parameters for admin organizations: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("error creating the client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } organizations, err := c.GetAdminOrganizations(&ims.GetAdminOrganizationsRequest{ diff --git a/ims/admin_profile.go b/ims/admin_profile.go index fa60a9d..f69da7c 100644 --- a/ims/admin_profile.go +++ b/ims/admin_profile.go @@ -50,17 +50,9 @@ func (i Config) GetAdminProfile() (string, error) { return "", fmt.Errorf("invalid parameters for admin profile: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("error creating the client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } profile, err := c.GetAdminProfile(&ims.GetAdminProfileRequest{ diff --git a/ims/authz_client.go b/ims/authz_client.go index 7704559..459207c 100644 --- a/ims/authz_client.go +++ b/ims/authz_client.go @@ -19,17 +19,9 @@ import ( // AuthorizeClientCredentials : Client Credentials OAuth flow func (i Config) AuthorizeClientCredentials() (string, error) { - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("create client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } r, err := c.Token(&ims.TokenRequest{ diff --git a/ims/authz_service.go b/ims/authz_service.go index 7936a65..623629e 100644 --- a/ims/authz_service.go +++ b/ims/authz_service.go @@ -19,17 +19,9 @@ import ( // AuthorizeService : Login for the service to service IMS flow func (i Config) AuthorizeService() (string, error) { - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("create client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } r, err := c.Token(&ims.TokenRequest{ diff --git a/ims/authz_user.go b/ims/authz_user.go index 340c86e..069c50f 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -70,17 +70,9 @@ func (i Config) AuthorizeUser() (string, error) { port = defaultPort } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("error during client creation: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } server, err := login.NewServer(&login.ServerConfig{ diff --git a/ims/config.go b/ims/config.go index c475ff7..a84034a 100644 --- a/ims/config.go +++ b/ims/config.go @@ -81,6 +81,17 @@ func (i Config) resolveToken() (string, ims.TokenType, error) { } } +func (i Config) newIMSClient() (*ims.Client, error) { + httpClient, err := i.httpClient() + if err != nil { + return nil, fmt.Errorf("error creating the HTTP client: %w", err) + } + return ims.NewClient(&ims.ClientConfig{ + URL: i.URL, + Client: httpClient, + }) +} + func validateURL(u string) bool { parsedURL, err := url.Parse(u) if err != nil { diff --git a/ims/exchange.go b/ims/exchange.go index 35e9a7d..6bd1d43 100644 --- a/ims/exchange.go +++ b/ims/exchange.go @@ -43,17 +43,9 @@ func (i Config) ClusterExchange() (TokenInfo, error) { return TokenInfo{}, fmt.Errorf("invalid parameters for cluster exchange: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return TokenInfo{}, fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return TokenInfo{}, fmt.Errorf("create client: %w", err) + return TokenInfo{}, fmt.Errorf("error creating the IMS client: %w", err) } r, err := c.ClusterExchange(&ims.ClusterExchangeRequest{ diff --git a/ims/invalidate.go b/ims/invalidate.go index 6218fdc..eda2112 100644 --- a/ims/invalidate.go +++ b/ims/invalidate.go @@ -53,17 +53,9 @@ func (i Config) InvalidateToken() error { return fmt.Errorf("incomplete parameters for token invalidation: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return fmt.Errorf("create client: %w", err) + return fmt.Errorf("error creating the IMS client: %w", err) } token, tokenType, err := i.resolveToken() diff --git a/ims/jwt_exchange.go b/ims/jwt_exchange.go index 89da7ec..a30277b 100644 --- a/ims/jwt_exchange.go +++ b/ims/jwt_exchange.go @@ -20,17 +20,9 @@ import ( func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return TokenInfo{}, fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return TokenInfo{}, fmt.Errorf("create client: %w", err) + return TokenInfo{}, fmt.Errorf("error creating the IMS client: %w", err) } key, err := os.ReadFile(i.PrivateKeyPath) diff --git a/ims/organizations.go b/ims/organizations.go index 9861e2f..5853c9b 100644 --- a/ims/organizations.go +++ b/ims/organizations.go @@ -44,17 +44,9 @@ func (i Config) GetOrganizations() (string, error) { return "", fmt.Errorf("invalid parameters for organizations: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("error creating the client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } organizations, err := c.GetOrganizations(&ims.GetOrganizationsRequest{ diff --git a/ims/profile.go b/ims/profile.go index 2f4806a..28112f4 100644 --- a/ims/profile.go +++ b/ims/profile.go @@ -49,17 +49,9 @@ func (i Config) GetProfile() (string, error) { return "", fmt.Errorf("invalid parameters for profile: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return "", fmt.Errorf("error creating the client: %w", err) + return "", fmt.Errorf("error creating the IMS client: %w", err) } profile, err := c.GetProfile(&ims.GetProfileRequest{ diff --git a/ims/refresh.go b/ims/refresh.go index b904452..185a258 100644 --- a/ims/refresh.go +++ b/ims/refresh.go @@ -38,17 +38,9 @@ func (i Config) Refresh() (RefreshInfo, error) { return RefreshInfo{}, fmt.Errorf("invalid parameters for token refresh: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return RefreshInfo{}, fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return RefreshInfo{}, fmt.Errorf("create client: %w", err) + return RefreshInfo{}, fmt.Errorf("error creating the IMS client: %w", err) } r, err := c.RefreshToken(&ims.RefreshTokenRequest{ diff --git a/ims/validate.go b/ims/validate.go index 35c094f..3495900 100644 --- a/ims/validate.go +++ b/ims/validate.go @@ -54,17 +54,9 @@ func (i Config) ValidateToken() (TokenInfo, error) { return TokenInfo{}, fmt.Errorf("invalid parameters for token validation: %w", err) } - httpClient, err := i.httpClient() + c, err := i.newIMSClient() if err != nil { - return TokenInfo{}, fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) - if err != nil { - return TokenInfo{}, fmt.Errorf("create client: %w", err) + return TokenInfo{}, fmt.Errorf("error creating the IMS client: %w", err) } token, tokenType, err := i.resolveToken() From 070b696c5ca98727f9cddbf4d5f6b355d35a76ae Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 15:31:20 +0100 Subject: [PATCH 2/8] Assorted fixes: SilenceErrors consolidation, input validation, tests, and hardening - Move SilenceErrors to root command, remove from 21 leaf commands (N17) - Add input validation for AuthorizeService and AuthorizeClientCredentials (6.2.3) - Save/restore browser.Stdout around OpenURL call (6.2.5) - Add defer listener.Close() in OAuth flow (N14) - Reject multiple tokens in resolveToken() (6.1.8) - Default version variable to "dev" for local builds (N16) - Add unit tests for all validators (43) - Add unicode/special char tests for pretty JSON (44) - Update CLAUDE.md with completed items --- CLAUDE.md | 57 ++--- cmd/admin/organizations.go | 2 +- cmd/admin/profile.go | 2 +- cmd/authz/client.go | 2 +- cmd/authz/jwt.go | 2 +- cmd/authz/pkce.go | 2 +- cmd/authz/service.go | 2 +- cmd/authz/user.go | 2 +- cmd/dcr.go | 2 +- cmd/decode.go | 2 +- cmd/exchange.go | 2 +- cmd/invalidate/access_token.go | 2 +- cmd/invalidate/device_token.go | 2 +- cmd/invalidate/refresh_token.go | 2 +- cmd/invalidate/service_token.go | 2 +- cmd/organizations.go | 2 +- cmd/pretty/json_test.go | 3 + cmd/profile.go | 2 +- cmd/refresh.go | 2 +- cmd/root.go | 3 +- cmd/validate/access_token.go | 2 +- cmd/validate/authorization_code.go | 2 +- cmd/validate/device_token.go | 2 +- cmd/validate/refresh_token.go | 2 +- ims/authz_client.go | 19 ++ ims/authz_service.go | 19 ++ ims/authz_user.go | 10 +- ims/config.go | 20 ++ ims/config_test.go | 393 +++++++++++++++++++++++++++++ main.go | 2 +- 30 files changed, 508 insertions(+), 60 deletions(-) create mode 100644 ims/config_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 491c527..4be1be8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,12 @@ # Code Review & Improvement Plan for imscli +## Preferences + +- Do not include "Generated with Claude Code" or similar attribution lines in commits or PRs. +- Always include file path and line numbers when referencing code (e.g., `ims/config.go:34`). + +--- + ## Summary Full review of all Go source files in the `imscli` project — a CLI tool for Adobe's @@ -78,13 +85,6 @@ The private key is read into a `[]byte` and passed to `ExchangeJWT`, but the byt slice is never zeroed after use. Key material persists in memory until GC. Should add `defer func() { for i := range key { key[i] = 0 } }()`. -#### 6.1.8 No mutual exclusion for token fields in validate/invalidate - -**Files:** `ims/validate.go:26-48`, `ims/invalidate.go:23-48` - -If multiple token fields are populated simultaneously, only the first match in the -switch wins silently. No validation checks that exactly one token is provided. - #### 6.1.12 PKCE flag mutation persists on shared Config pointer **File:** `cmd/authz/pkce.go:30` @@ -98,25 +98,10 @@ switch wins silently. No validation checks that exactly one token is provided. Set in `jwt_exchange.go:65`, `exchange.go:73`, `refresh.go:67` with wrong calculations, but no caller ever reads the field. Dead code with active bugs. -#### 6.2.3 Missing input validation in `AuthorizeService` and `AuthorizeClientCredentials` - -`ims/authz_service.go` and `ims/authz_client.go` are the only two operation methods -without a `validateXxxConfig()` function. - -#### 6.2.5 `browser.Stdout = nil` has global side effects - -**File:** `ims/authz_user.go:123` - -Modifies a package-level variable in `pkg/browser`. Should save/restore the original. - #### 6.2.6 Inconsistent flag shorthands across commands `-s` and `-a` mean different things in different commands. -#### 6.2.8 `cmd/profile.go:41` — default API version outdated - -Profile API version defaults to `"v1"` but latest is `"v3"`. - #### 6.2.9 Hardcoded serviceCode whitelist in profile decoding **File:** `ims/profile.go:108-111` @@ -139,18 +124,11 @@ Hardcoded `v1`-`v6` whitelist requires code changes for new versions. | Finding | File | Description | |---------|------|-------------| -| No test for unicode/special chars | `cmd/pretty/json_test.go` | Missing edge case coverage | | `RawURLEncoding` rejects padded base64 | `ims/decode.go:53` | Some JWT impls include `=` padding | -| No JWE support (5-part tokens) | `ims/decode.go:43` | Only 3-part JWTs supported | -| Gzip `io.Copy(io.Discard)` unnecessary | `ims/profile.go:147` | JSON decoder already consumed stream | | `PersistentPreRunE` can be overridden | `cmd/root.go:31` | If a subcommand defines its own, parent's is lost | | Duplicate port defaults (cobra + const) | `cmd/authz/user.go:44` + `ims/authz_user.go:27` | Maintenance risk | | `cmd/refresh.go:36-39` uses `map[string]interface{}` | `cmd/refresh.go` | No guaranteed field order in JSON; use struct | -| `viper.Unmarshal` ignores unknown keys | `cmd/params.go:69` | Config typos silently ignored | -| Listener not closed on panic path | `ims/authz_user.go:112-130` | Missing `defer listener.Close()` | | Cascading/ClientSecret unconditionally sent | `ims/invalidate.go:91-97` | Should be conditional on token type | -| `version` variable has no default | `main.go:22` | Shows empty if ldflags not set | -| Double error printing from cobra | `main.go:27-29` | Root cmd doesn't SilenceErrors; leaf cmds do | --- @@ -162,17 +140,13 @@ Hardcoded `v1`-`v6` whitelist requires code changes for new versions. |---|--------|-------| | 33 | Deduplicate validate subcommands with factory | `cmd/validate/*.go` | | 34 | Deduplicate invalidate subcommands with factory | `cmd/invalidate/*.go` | -| 38 | Remove empty `internal/output/` directory | directory | | 40 | Merge `pkce.go`/`user.go` into factory | `cmd/authz/` | -| 42 | Update default profile API version to v3 | `cmd/profile.go` | ### Testing | # | Change | Files | |---|--------|-------| -| 43 | Add unit tests for all validators | `ims/*_test.go` (new) | -| 44 | Add unicode/special char tests for pretty | `cmd/pretty/json_test.go` | -| 46 | Add integration tests for flag/config/env precedence | `cmd/*_test.go` (new) | +| 46 | Integration tests for flag/config/env precedence (requires mock IMS server) | `cmd/*_test.go` (new) | --- @@ -216,6 +190,17 @@ The following items have been implemented and merged: - **2.1** Rename `ProfileApiVersion` → `ProfileAPIVersion`, `OrgsApiVersion` → `OrgsAPIVersion` (#70) - **2.5** Replace C-style `/* */` block comments with `//` line comments (#70) - **3.1** Extract `newIMSClient()` helper, deduplicate 13 call sites (#70) +- **N17** Move `SilenceErrors` to root command, remove from 21 leaf commands (#70) +- **6.2.3** Add input validation for `AuthorizeService` and `AuthorizeClientCredentials` (#70) +- **6.2.5** Save/restore `browser.Stdout` around `OpenURL` call (#70) +- **N16** Default `version` variable to `"dev"` for local builds (#70) +- **N14** Add `defer listener.Close()` in OAuth flow (#70) +- **43** Add unit tests for all validators (`ims/config_test.go`) (#70) +- **44** Add unicode/special char tests for pretty (`cmd/pretty/json_test.go`) (#70) +- **38** Remove empty `internal/output/` directory — already gone after #68 refactor +- **45** Parallel test safety for `output/output_test.go` — resolved by #68 (output package removed, `cmd/pretty` tests a pure function) +- **N15** Unicode/special char tests — completed as part of item 44 (#70) +- **6.1.8** Reject multiple tokens in `resolveToken()` (`ims/config.go:67`) (#70) ### Skipped (not applicable) @@ -224,3 +209,7 @@ The following items have been implemented and merged: - **6.1.10** Default metascopes — covered by 1.9 - **3.8** `MarkFlagRequired` — incompatible with viper config/env workflow - **6.1.13** Missing Config `String()` — Config is never printed in the CLI +- **N8** No JWE support — decode command is for plain JWTs, JWE is a different feature +- **N9** Gzip `io.Copy(io.Discard)` — intentionally kept as an example pattern +- **N13** `viper.Unmarshal` ignoring unknown keys — `UnmarshalExact` would break forward compatibility +- **6.2.8 / 42** Default profile API version v1 — intentional for backward compatibility diff --git a/cmd/admin/organizations.go b/cmd/admin/organizations.go index bd99b61..5318d35 100644 --- a/cmd/admin/organizations.go +++ b/cmd/admin/organizations.go @@ -27,7 +27,7 @@ func OrganizationsCmd(imsConfig *ims.Config) *cobra.Command { Long: "Requests the specified user organizations using the admin API and a service token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.GetAdminOrganizations() if err != nil { diff --git a/cmd/admin/profile.go b/cmd/admin/profile.go index 02db659..bcce154 100644 --- a/cmd/admin/profile.go +++ b/cmd/admin/profile.go @@ -25,7 +25,7 @@ func ProfileCmd(imsConfig *ims.Config) *cobra.Command { Long: "Requests the specified user profile using the admin API and a service token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.GetAdminProfile() if err != nil { diff --git a/cmd/authz/client.go b/cmd/authz/client.go index 6aa96b0..0ccff5c 100644 --- a/cmd/authz/client.go +++ b/cmd/authz/client.go @@ -25,7 +25,7 @@ func ClientCredentialsCmd(imsConfig *ims.Config) *cobra.Command { Long: "Perform the 'Client Credentials Authorization Flow' to negotiate an access token for a service.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.AuthorizeClientCredentials() if err != nil { diff --git a/cmd/authz/jwt.go b/cmd/authz/jwt.go index bf11a48..9ab54d2 100644 --- a/cmd/authz/jwt.go +++ b/cmd/authz/jwt.go @@ -24,7 +24,7 @@ func JWTCmd(imsConfig *ims.Config) *cobra.Command { Long: "Perform the 'Assertion Grant Type Flow' to request a token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.AuthorizeJWTExchange() if err != nil { diff --git a/cmd/authz/pkce.go b/cmd/authz/pkce.go index 28dad05..463e286 100644 --- a/cmd/authz/pkce.go +++ b/cmd/authz/pkce.go @@ -26,7 +26,7 @@ func UserPkceCmd(imsConfig *ims.Config) *cobra.Command { "returned IMS token. Public and private clients are supported.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + imsConfig.PKCE = true resp, err := imsConfig.AuthorizeUser() if err != nil { diff --git a/cmd/authz/service.go b/cmd/authz/service.go index 4a592b5..391e58c 100644 --- a/cmd/authz/service.go +++ b/cmd/authz/service.go @@ -24,7 +24,7 @@ func ServiceCmd(imsConfig *ims.Config) *cobra.Command { Long: "Perform the 'Authorization Code Exchange' to negotiate an access token for a service.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.AuthorizeService() if err != nil { diff --git a/cmd/authz/user.go b/cmd/authz/user.go index b7ecc72..471982e 100644 --- a/cmd/authz/user.go +++ b/cmd/authz/user.go @@ -26,7 +26,7 @@ func UserCmd(imsConfig *ims.Config) *cobra.Command { "IMS token. Without PKCE only private clients are supported.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.AuthorizeUser() if err != nil { diff --git a/cmd/dcr.go b/cmd/dcr.go index 1516fea..c794984 100644 --- a/cmd/dcr.go +++ b/cmd/dcr.go @@ -26,7 +26,7 @@ func registerCmd(imsConfig *ims.Config) *cobra.Command { Long: `Register a new OAuth client using Dynamic Client Registration.`, RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.Register() if err != nil { diff --git a/cmd/decode.go b/cmd/decode.go index 727328a..a5a0a3b 100644 --- a/cmd/decode.go +++ b/cmd/decode.go @@ -26,7 +26,7 @@ func decodeCmd(imsConfig *ims.Config) *cobra.Command { 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 + decoded, err := imsConfig.DecodeToken() if err != nil { diff --git a/cmd/exchange.go b/cmd/exchange.go index 4ac7ee0..776300e 100644 --- a/cmd/exchange.go +++ b/cmd/exchange.go @@ -25,7 +25,7 @@ func exchangeCmd(imsConfig *ims.Config) *cobra.Command { Long: "Perform the 'Cluster Access Token Exchange Grant' to request a new token with a new user ID or IMS Org ID.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.ClusterExchange() if err != nil { diff --git a/cmd/invalidate/access_token.go b/cmd/invalidate/access_token.go index a8fdee2..ffe42f8 100644 --- a/cmd/invalidate/access_token.go +++ b/cmd/invalidate/access_token.go @@ -25,7 +25,7 @@ func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Invalidate an access token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + err := imsConfig.InvalidateToken() if err != nil { diff --git a/cmd/invalidate/device_token.go b/cmd/invalidate/device_token.go index b5f000e..5ec8391 100644 --- a/cmd/invalidate/device_token.go +++ b/cmd/invalidate/device_token.go @@ -25,7 +25,7 @@ func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "invalidate a device token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + err := imsConfig.InvalidateToken() if err != nil { diff --git a/cmd/invalidate/refresh_token.go b/cmd/invalidate/refresh_token.go index 90f80d1..6bd0334 100644 --- a/cmd/invalidate/refresh_token.go +++ b/cmd/invalidate/refresh_token.go @@ -25,7 +25,7 @@ func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Invalidate a refresh token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + err := imsConfig.InvalidateToken() if err != nil { diff --git a/cmd/invalidate/service_token.go b/cmd/invalidate/service_token.go index a6784ab..91c5264 100644 --- a/cmd/invalidate/service_token.go +++ b/cmd/invalidate/service_token.go @@ -25,7 +25,7 @@ func ServiceTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Invalidate a service token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + err := imsConfig.InvalidateToken() if err != nil { diff --git a/cmd/organizations.go b/cmd/organizations.go index ea74c55..e60d66f 100644 --- a/cmd/organizations.go +++ b/cmd/organizations.go @@ -27,7 +27,7 @@ func organizationsCmd(imsConfig *ims.Config) *cobra.Command { Long: "Requests the user organizations associated to the provided access token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.GetOrganizations() if err != nil { diff --git a/cmd/pretty/json_test.go b/cmd/pretty/json_test.go index d8acc7b..676ceab 100644 --- a/cmd/pretty/json_test.go +++ b/cmd/pretty/json_test.go @@ -40,6 +40,9 @@ func TestJSON(t *testing.T) { {name: "nested objects", input: `{"a":{"b":{"c":1}}}`, file: "nested_objects.json"}, {name: "non-JSON is returned as-is", input: "this is not JSON", file: "non_json.txt"}, {name: "empty string is returned as-is", input: "", file: "empty_string.txt"}, + {name: "unicode characters", input: `{"greeting":"こんにちは","emoji":"🎉","accented":"café"}`, file: "unicode.json"}, + {name: "special characters", input: `{"html":"\u003cscript\u003ealert('xss')\u003c/script\u003e","newlines":"line1\nline2","tabs":"col1\tcol2","quotes":"she said \"hello\""}`, file: "special_chars.json"}, + {name: "null and bool values", input: `{"value":null,"enabled":true,"disabled":false}`, file: "null_and_bool.json"}, } for _, tt := range tests { diff --git a/cmd/profile.go b/cmd/profile.go index c454823..4ad9eec 100644 --- a/cmd/profile.go +++ b/cmd/profile.go @@ -26,7 +26,7 @@ func profileCmd(imsConfig *ims.Config) *cobra.Command { Long: "Requests the user profile associated to the provided access token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.GetProfile() if err != nil { diff --git a/cmd/refresh.go b/cmd/refresh.go index 7f2d20e..648b59b 100644 --- a/cmd/refresh.go +++ b/cmd/refresh.go @@ -26,7 +26,7 @@ func refreshCmd(imsConfig *ims.Config) *cobra.Command { Long: "Exchange a refresh token for new access and refresh tokens.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.Refresh() if err != nil { diff --git a/cmd/root.go b/cmd/root.go index ea2cc4d..d394265 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,8 @@ func RootCmd(version string) *cobra.Command { Use: "imscli", Short: "imscli is a tool to interact with Adobe IMS", Long: `imscli is a CLI tool to automate and troubleshoot Adobe's authentication and authorization service IMS.`, - Version: version, + Version: version, + SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if !verbose { log.SetOutput(io.Discard) diff --git a/cmd/validate/access_token.go b/cmd/validate/access_token.go index 70f4428..b288d43 100644 --- a/cmd/validate/access_token.go +++ b/cmd/validate/access_token.go @@ -26,7 +26,7 @@ func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Validate an access token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.ValidateToken() if err != nil { diff --git a/cmd/validate/authorization_code.go b/cmd/validate/authorization_code.go index 66337bc..c910360 100644 --- a/cmd/validate/authorization_code.go +++ b/cmd/validate/authorization_code.go @@ -26,7 +26,7 @@ func AuthzCodeCmd(imsConfig *ims.Config) *cobra.Command { Long: "Validate an authorization code.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.ValidateToken() if err != nil { diff --git a/cmd/validate/device_token.go b/cmd/validate/device_token.go index 7209bfc..53807cf 100644 --- a/cmd/validate/device_token.go +++ b/cmd/validate/device_token.go @@ -26,7 +26,7 @@ func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Validate a device token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.ValidateToken() if err != nil { diff --git a/cmd/validate/refresh_token.go b/cmd/validate/refresh_token.go index e149111..579bfe4 100644 --- a/cmd/validate/refresh_token.go +++ b/cmd/validate/refresh_token.go @@ -26,7 +26,7 @@ func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { Long: "Validate a refresh token.", RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - cmd.SilenceErrors = true + resp, err := imsConfig.ValidateToken() if err != nil { diff --git a/ims/authz_client.go b/ims/authz_client.go index 459207c..a4f26c9 100644 --- a/ims/authz_client.go +++ b/ims/authz_client.go @@ -16,9 +16,28 @@ import ( "github.com/adobe/ims-go/ims" ) +func (i Config) validateAuthorizeClientCredentialsConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing 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 len(i.Scopes) == 0 || i.Scopes[0] == "": + return fmt.Errorf("missing scopes parameter") + default: + return nil + } +} + // AuthorizeClientCredentials : Client Credentials OAuth flow func (i Config) AuthorizeClientCredentials() (string, error) { + if err := i.validateAuthorizeClientCredentialsConfig(); err != nil { + return "", fmt.Errorf("invalid parameters for client credentials authorization: %w", err) + } + c, err := i.newIMSClient() if err != nil { return "", fmt.Errorf("error creating the IMS client: %w", err) diff --git a/ims/authz_service.go b/ims/authz_service.go index 623629e..3cc24db 100644 --- a/ims/authz_service.go +++ b/ims/authz_service.go @@ -16,9 +16,28 @@ import ( "github.com/adobe/ims-go/ims" ) +func (i Config) validateAuthorizeServiceConfig() error { + switch { + case i.URL == "": + return fmt.Errorf("missing 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.AuthorizationCode == "": + return fmt.Errorf("missing authorization code parameter") + default: + return nil + } +} + // AuthorizeService : Login for the service to service IMS flow func (i Config) AuthorizeService() (string, error) { + if err := i.validateAuthorizeServiceConfig(); err != nil { + return "", fmt.Errorf("invalid parameters for service authorization: %w", err) + } + c, err := i.newIMSClient() if err != nil { return "", fmt.Errorf("error creating the IMS client: %w", err) diff --git a/ims/authz_user.go b/ims/authz_user.go index 069c50f..b02cba2 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -103,16 +103,20 @@ func (i Config) AuthorizeUser() (string, error) { if err != nil { return "", fmt.Errorf("unable to listen at port %d", port) } + defer listener.Close() log.Println("Local server successfully launched and contacted.") localUrl := fmt.Sprintf("http://localhost:%d/", port) - // redirect stdout to avoid "Opening in existing browser session." message from chromium - // The token is expected in stdout and this type of messages disrupt scripts + // Suppress "Opening in existing browser session." messages from chromium-based + // browsers. The CLI token output goes to stdout, so stray browser messages + // would corrupt piped/scripted output. Save and restore to avoid permanent + // mutation of the package-level variable. + origStdout := browser.Stdout browser.Stdout = nil - err = browser.OpenURL(localUrl) + browser.Stdout = origStdout if err != nil { fmt.Fprintf(os.Stderr, "error launching the browser, open it and visit %s\n", localUrl) } diff --git a/ims/config.go b/ims/config.go index a84034a..103e4e2 100644 --- a/ims/config.go +++ b/ims/config.go @@ -65,6 +65,26 @@ type RefreshInfo struct { } func (i Config) resolveToken() (string, ims.TokenType, error) { + count := 0 + if i.AccessToken != "" { + count++ + } + if i.RefreshToken != "" { + count++ + } + if i.DeviceToken != "" { + count++ + } + if i.ServiceToken != "" { + count++ + } + if i.AuthorizationCode != "" { + count++ + } + if count > 1 { + return "", "", fmt.Errorf("multiple tokens provided, expected exactly one") + } + switch { case i.AccessToken != "": return i.AccessToken, ims.AccessToken, nil diff --git a/ims/config_test.go b/ims/config_test.go new file mode 100644 index 0000000..905d6df --- /dev/null +++ b/ims/config_test.go @@ -0,0 +1,393 @@ +// 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 ( + "testing" +) + +func TestValidateDecodeTokenConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{Token: "a.b.c"}, wantErr: ""}, + {name: "missing token", config: Config{}, wantErr: "missing token parameter"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateDecodeTokenConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateValidateTokenConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid with access token", config: Config{ClientID: "c", URL: "https://ims.example.com", AccessToken: "tok"}, wantErr: ""}, + {name: "valid with refresh token", config: Config{ClientID: "c", URL: "https://ims.example.com", RefreshToken: "tok"}, wantErr: ""}, + {name: "valid with device token", config: Config{ClientID: "c", URL: "https://ims.example.com", DeviceToken: "tok"}, wantErr: ""}, + {name: "valid with authorization code", config: Config{ClientID: "c", URL: "https://ims.example.com", AuthorizationCode: "tok"}, wantErr: ""}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", AccessToken: "tok"}, wantErr: "missing clientID"}, + {name: "missing URL", config: Config{ClientID: "c", AccessToken: "tok"}, wantErr: "missing IMS base URL"}, + {name: "missing token", config: Config{ClientID: "c", URL: "https://ims.example.com"}, wantErr: "no token type has been found"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateValidateTokenConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateInvalidateTokenConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid with access token", config: Config{ClientID: "c", URL: "https://ims.example.com", AccessToken: "tok"}, wantErr: ""}, + {name: "valid with refresh token", config: Config{ClientID: "c", URL: "https://ims.example.com", RefreshToken: "tok"}, wantErr: ""}, + {name: "valid with device token", config: Config{ClientID: "c", URL: "https://ims.example.com", DeviceToken: "tok"}, wantErr: ""}, + {name: "valid with service token", config: Config{ClientID: "c", URL: "https://ims.example.com", ServiceToken: "tok", ClientSecret: "s"}, wantErr: ""}, + {name: "service token without secret", config: Config{ClientID: "c", URL: "https://ims.example.com", ServiceToken: "tok"}, wantErr: "missing client secret"}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", AccessToken: "tok"}, wantErr: "missing clientID"}, + {name: "missing URL", config: Config{ClientID: "c", AccessToken: "tok"}, wantErr: "missing IMS base URL"}, + {name: "missing token", config: Config{ClientID: "c", URL: "https://ims.example.com"}, wantErr: "no token has been found"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateInvalidateTokenConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateGetProfileConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{ProfileAPIVersion: "v2", AccessToken: "tok", URL: "https://ims.example.com"}, wantErr: ""}, + {name: "invalid version", config: Config{ProfileAPIVersion: "v99", AccessToken: "tok", URL: "https://ims.example.com"}, wantErr: "invalid API version"}, + {name: "missing access token", config: Config{ProfileAPIVersion: "v1", URL: "https://ims.example.com"}, wantErr: "missing access token"}, + {name: "missing URL", config: Config{ProfileAPIVersion: "v1", AccessToken: "tok"}, wantErr: "missing IMS base URL"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateGetProfileConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateGetAdminProfileConfig(t *testing.T) { + validConfig := Config{ + ProfileAPIVersion: "v2", + ServiceToken: "tok", + URL: "https://ims.example.com", + ClientID: "c", + Guid: "g", + AuthSrc: "a", + } + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: validConfig, wantErr: ""}, + {name: "invalid version", config: withField(validConfig, func(c *Config) { c.ProfileAPIVersion = "v99" }), wantErr: "invalid API version"}, + {name: "missing service token", config: withField(validConfig, func(c *Config) { c.ServiceToken = "" }), wantErr: "missing service token"}, + {name: "missing URL", config: withField(validConfig, func(c *Config) { c.URL = "" }), wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: withField(validConfig, func(c *Config) { c.ClientID = "" }), wantErr: "missing client ID"}, + {name: "missing guid", config: withField(validConfig, func(c *Config) { c.Guid = "" }), wantErr: "missing guid"}, + {name: "missing authSrc", config: withField(validConfig, func(c *Config) { c.AuthSrc = "" }), wantErr: "missing auth source"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateGetAdminProfileConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateGetOrganizationsConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{OrgsAPIVersion: "v5", AccessToken: "tok", URL: "https://ims.example.com"}, wantErr: ""}, + {name: "invalid version", config: Config{OrgsAPIVersion: "v99", AccessToken: "tok", URL: "https://ims.example.com"}, wantErr: "invalid API version"}, + {name: "missing access token", config: Config{OrgsAPIVersion: "v5", URL: "https://ims.example.com"}, wantErr: "missing access token"}, + {name: "missing URL", config: Config{OrgsAPIVersion: "v5", AccessToken: "tok"}, wantErr: "missing IMS base URL"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateGetOrganizationsConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateGetAdminOrganizationsConfig(t *testing.T) { + validConfig := Config{ + OrgsAPIVersion: "v5", + ServiceToken: "tok", + URL: "https://ims.example.com", + ClientID: "c", + Guid: "g", + AuthSrc: "a", + } + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: validConfig, wantErr: ""}, + {name: "invalid version", config: withField(validConfig, func(c *Config) { c.OrgsAPIVersion = "v99" }), wantErr: "invalid API version"}, + {name: "missing service token", config: withField(validConfig, func(c *Config) { c.ServiceToken = "" }), wantErr: "missing service token"}, + {name: "missing URL", config: withField(validConfig, func(c *Config) { c.URL = "" }), wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: withField(validConfig, func(c *Config) { c.ClientID = "" }), wantErr: "missing client ID"}, + {name: "missing guid", config: withField(validConfig, func(c *Config) { c.Guid = "" }), wantErr: "missing guid"}, + {name: "missing authSrc", config: withField(validConfig, func(c *Config) { c.AuthSrc = "" }), wantErr: "missing auth source"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateGetAdminOrganizationsConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateClusterExchangeConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid with org", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", AccessToken: "tok", Organization: "org"}, wantErr: ""}, + {name: "valid with userID", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", AccessToken: "tok", UserID: "u"}, wantErr: ""}, + {name: "missing URL", config: Config{ClientID: "c", ClientSecret: "s", AccessToken: "tok", Organization: "org"}, wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", ClientSecret: "s", AccessToken: "tok", Organization: "org"}, wantErr: "missing client ID"}, + {name: "missing secret", config: Config{URL: "https://ims.example.com", ClientID: "c", AccessToken: "tok", Organization: "org"}, wantErr: "missing client secret"}, + {name: "missing access token", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", Organization: "org"}, wantErr: "missing access token"}, + {name: "both org and userID", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", AccessToken: "tok", Organization: "org", UserID: "u"}, wantErr: "can't perform the request"}, + {name: "neither org nor userID", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", AccessToken: "tok"}, wantErr: "missing user ID or IMS Organization"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateClusterExchangeConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateRefreshConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", RefreshToken: "tok"}, wantErr: ""}, + {name: "missing URL", config: Config{ClientID: "c", ClientSecret: "s", RefreshToken: "tok"}, wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", ClientSecret: "s", RefreshToken: "tok"}, wantErr: "missing client ID"}, + {name: "missing secret", config: Config{URL: "https://ims.example.com", ClientID: "c", RefreshToken: "tok"}, wantErr: "missing client secret"}, + {name: "missing refresh token", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s"}, wantErr: "missing refresh token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateRefreshConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateAuthorizeUserConfig(t *testing.T) { + validConfig := Config{ + URL: "https://ims.example.com", + ClientID: "c", + ClientSecret: "s", + Organization: "org", + Scopes: []string{"openid"}, + } + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: validConfig, wantErr: ""}, + {name: "valid public client", config: withField(validConfig, func(c *Config) { c.ClientSecret = ""; c.PublicClient = true }), wantErr: ""}, + {name: "missing URL", config: withField(validConfig, func(c *Config) { c.URL = "" }), wantErr: "missing IMS base URL"}, + {name: "invalid URL", config: withField(validConfig, func(c *Config) { c.URL = "not-a-url" }), wantErr: "unable to parse URL"}, + {name: "missing scopes", config: withField(validConfig, func(c *Config) { c.Scopes = nil }), wantErr: "missing scopes"}, + {name: "empty scopes", config: withField(validConfig, func(c *Config) { c.Scopes = []string{""} }), wantErr: "missing scopes"}, + {name: "missing clientID", config: withField(validConfig, func(c *Config) { c.ClientID = "" }), wantErr: "missing client id"}, + {name: "missing organization", config: withField(validConfig, func(c *Config) { c.Organization = "" }), wantErr: "missing organization"}, + {name: "missing secret non-public", config: withField(validConfig, func(c *Config) { c.ClientSecret = "" }), wantErr: "missing client secret"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateAuthorizeUserConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateAuthorizeServiceConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", AuthorizationCode: "code"}, wantErr: ""}, + {name: "missing URL", config: Config{ClientID: "c", ClientSecret: "s", AuthorizationCode: "code"}, wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", ClientSecret: "s", AuthorizationCode: "code"}, wantErr: "missing client ID"}, + {name: "missing secret", config: Config{URL: "https://ims.example.com", ClientID: "c", AuthorizationCode: "code"}, wantErr: "missing client secret"}, + {name: "missing code", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s"}, wantErr: "missing authorization code"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateAuthorizeServiceConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateAuthorizeClientCredentialsConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", Scopes: []string{"openid"}}, wantErr: ""}, + {name: "missing URL", config: Config{ClientID: "c", ClientSecret: "s", Scopes: []string{"openid"}}, wantErr: "missing IMS base URL"}, + {name: "missing clientID", config: Config{URL: "https://ims.example.com", ClientSecret: "s", Scopes: []string{"openid"}}, wantErr: "missing client ID"}, + {name: "missing secret", config: Config{URL: "https://ims.example.com", ClientID: "c", Scopes: []string{"openid"}}, wantErr: "missing client secret"}, + {name: "missing scopes", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s"}, wantErr: "missing scopes"}, + {name: "empty scopes", config: Config{URL: "https://ims.example.com", ClientID: "c", ClientSecret: "s", Scopes: []string{""}}, wantErr: "missing scopes"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateAuthorizeClientCredentialsConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestValidateRegisterConfig(t *testing.T) { + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "valid", config: Config{RegisterURL: "https://example.com/register", ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: ""}, + {name: "missing URL", config: Config{ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing registration endpoint URL"}, + {name: "missing client name", config: Config{RegisterURL: "https://example.com/register", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing client name"}, + {name: "missing redirect URIs", config: Config{RegisterURL: "https://example.com/register", ClientName: "app"}, wantErr: "missing redirect URIs"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.validateRegisterConfig() + assertError(t, err, tt.wantErr) + }) + } +} + +func TestResolveToken(t *testing.T) { + tests := []struct { + name string + config Config + wantToken string + wantErr string + }{ + {name: "access token", config: Config{AccessToken: "a"}, wantToken: "a"}, + {name: "refresh token", config: Config{RefreshToken: "r"}, wantToken: "r"}, + {name: "device token", config: Config{DeviceToken: "d"}, wantToken: "d"}, + {name: "service token", config: Config{ServiceToken: "s"}, wantToken: "s"}, + {name: "authorization code", config: Config{AuthorizationCode: "c"}, wantToken: "c"}, + {name: "no token", config: Config{}, wantErr: "no token provided"}, + {name: "multiple tokens", config: Config{AccessToken: "a", RefreshToken: "r"}, wantErr: "multiple tokens provided"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, _, err := tt.config.resolveToken() + assertError(t, err, tt.wantErr) + if err == nil && token != tt.wantToken { + t.Errorf("resolveToken() = %q, want %q", token, tt.wantToken) + } + }) + } +} + +func TestValidateURL(t *testing.T) { + tests := []struct { + name string + url string + want bool + }{ + {name: "valid https", url: "https://ims.example.com", want: true}, + {name: "valid http", url: "http://localhost:8080", want: true}, + {name: "missing scheme", url: "ims.example.com", want: false}, + {name: "missing host", url: "https://", want: false}, + {name: "empty", url: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := validateURL(tt.url); got != tt.want { + t.Errorf("validateURL(%q) = %v, want %v", tt.url, got, tt.want) + } + }) + } +} + +// withField returns a copy of the config with the given mutation applied. +func withField(c Config, mutate func(*Config)) Config { + mutate(&c) + return c +} + +// assertError checks that err matches the expected substring, or is nil if wantErr is empty. +func assertError(t *testing.T, err error, wantErr string) { + t.Helper() + if wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Errorf("expected error containing %q, got nil", wantErr) + return + } + if got := err.Error(); !contains(got, wantErr) { + t.Errorf("error %q does not contain %q", got, wantErr) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/main.go b/main.go index 995d2b0..bda6055 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ import ( // To be initialized with ldflags -X main.version // Done automatically by goreleaser -var version string +var version = "dev" func main() { rootCmd := cmd.RootCmd(version) From 2259695a7bc285c8c0ac025a63c17449893cd75d Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 15:45:31 +0100 Subject: [PATCH 3/8] Deduplicate validate and invalidate subcommands with factory functions Replace 4 nearly-identical files in each of cmd/validate/ and cmd/invalidate/ with a single file using a factory pattern. Public API unchanged. --- cmd/invalidate/access_token.go | 43 ------------- cmd/invalidate/device_token.go | 45 -------------- cmd/invalidate/invalidate.go | 98 ++++++++++++++++++++++++++++++ cmd/invalidate/refresh_token.go | 45 -------------- cmd/invalidate/service_token.go | 44 -------------- cmd/validate/access_token.go | 47 -------------- cmd/validate/authorization_code.go | 47 -------------- cmd/validate/device_token.go | 47 -------------- cmd/validate/refresh_token.go | 47 -------------- cmd/validate/validate.go | 82 +++++++++++++++++++++++++ 10 files changed, 180 insertions(+), 365 deletions(-) delete mode 100644 cmd/invalidate/access_token.go delete mode 100644 cmd/invalidate/device_token.go create mode 100644 cmd/invalidate/invalidate.go delete mode 100644 cmd/invalidate/refresh_token.go delete mode 100644 cmd/invalidate/service_token.go delete mode 100644 cmd/validate/access_token.go delete mode 100644 cmd/validate/authorization_code.go delete mode 100644 cmd/validate/device_token.go delete mode 100644 cmd/validate/refresh_token.go create mode 100644 cmd/validate/validate.go diff --git a/cmd/invalidate/access_token.go b/cmd/invalidate/access_token.go deleted file mode 100644 index ffe42f8..0000000 --- a/cmd/invalidate/access_token.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2021 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 invalidate - -import ( - "fmt" - - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "accessToken", - Aliases: []string{"acc"}, - Short: "Invalidate an access token.", - Long: "Invalidate an access token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - err := imsConfig.InvalidateToken() - if err != nil { - return fmt.Errorf("error invalidating the access token: %w", err) - } - fmt.Println("Token invalidated successfully.") - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.AccessToken, "accessToken", "t", "", "Access token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - - return cmd -} diff --git a/cmd/invalidate/device_token.go b/cmd/invalidate/device_token.go deleted file mode 100644 index 5ec8391..0000000 --- a/cmd/invalidate/device_token.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2021 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 invalidate - -import ( - "fmt" - - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "deviceToken", - Aliases: []string{"dev"}, - Short: "invalidate a device token.", - Long: "invalidate a device token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - err := imsConfig.InvalidateToken() - if err != nil { - return fmt.Errorf("error invalidating the device token: %w", err) - } - fmt.Println("Token invalidated successfully.") - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.DeviceToken, "deviceToken", "t", "", "Device token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - cmd.Flags().BoolVarP(&imsConfig.Cascading, "cascading", "a", false, - "Also invalidate all tokens obtained with the device token.") - - return cmd -} diff --git a/cmd/invalidate/invalidate.go b/cmd/invalidate/invalidate.go new file mode 100644 index 0000000..714b166 --- /dev/null +++ b/cmd/invalidate/invalidate.go @@ -0,0 +1,98 @@ +// Copyright 2021 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 invalidate + +import ( + "fmt" + + "github.com/adobe/imscli/ims" + "github.com/spf13/cobra" +) + +type tokenDef struct { + use string + alias string + label string + flagName string + field *string + successMsg string + extraFlags func(cmd *cobra.Command, imsConfig *ims.Config) +} + +func tokenCmd(imsConfig *ims.Config, def tokenDef) *cobra.Command { + cmd := &cobra.Command{ + Use: def.use, + Aliases: []string{def.alias}, + Short: fmt.Sprintf("Invalidate %s.", def.label), + Long: fmt.Sprintf("Invalidate %s.", def.label), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + err := imsConfig.InvalidateToken() + if err != nil { + return fmt.Errorf("error invalidating the %s: %w", def.label, err) + } + fmt.Println(def.successMsg) + return nil + }, + } + + cmd.Flags().StringVarP(def.field, def.flagName, "t", "", def.label+".") + cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") + if def.extraFlags != nil { + def.extraFlags(cmd, imsConfig) + } + + return cmd +} + +func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "accessToken", alias: "acc", label: "access token", + flagName: "accessToken", field: &imsConfig.AccessToken, + successMsg: "Token invalidated successfully.", + }) +} + +func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "refreshToken", alias: "ref", label: "refresh token", + flagName: "refreshToken", field: &imsConfig.RefreshToken, + successMsg: "Refresh token successfully invalidated.", + extraFlags: func(cmd *cobra.Command, c *ims.Config) { + cmd.Flags().BoolVarP(&c.Cascading, "cascading", "a", false, + "Also invalidate all tokens obtained with the refresh token.") + }, + }) +} + +func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "deviceToken", alias: "dev", label: "device token", + flagName: "deviceToken", field: &imsConfig.DeviceToken, + successMsg: "Token invalidated successfully.", + extraFlags: func(cmd *cobra.Command, c *ims.Config) { + cmd.Flags().BoolVarP(&c.Cascading, "cascading", "a", false, + "Also invalidate all tokens obtained with the device token.") + }, + }) +} + +func ServiceTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "serviceToken", alias: "svc", label: "service token", + flagName: "serviceToken", field: &imsConfig.ServiceToken, + successMsg: "Service token successfully invalidated.", + extraFlags: func(cmd *cobra.Command, c *ims.Config) { + cmd.Flags().StringVarP(&c.ClientSecret, "clientSecret", "s", "", "IMS Client Secret.") + }, + }) +} diff --git a/cmd/invalidate/refresh_token.go b/cmd/invalidate/refresh_token.go deleted file mode 100644 index 6bd0334..0000000 --- a/cmd/invalidate/refresh_token.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2021 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 invalidate - -import ( - "fmt" - - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "refreshToken", - Aliases: []string{"ref"}, - Short: "Invalidate a refresh token.", - Long: "Invalidate a refresh token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - err := imsConfig.InvalidateToken() - if err != nil { - return fmt.Errorf("error invalidating the refresh token: %w", err) - } - fmt.Println("Refresh token successfully invalidated.") - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.RefreshToken, "refreshToken", "t", "", "Refresh token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - cmd.Flags().BoolVarP(&imsConfig.Cascading, "cascading", "a", false, - "Also invalidate all tokens obtained with the refresh token.") - - return cmd -} diff --git a/cmd/invalidate/service_token.go b/cmd/invalidate/service_token.go deleted file mode 100644 index 91c5264..0000000 --- a/cmd/invalidate/service_token.go +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright 2021 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 invalidate - -import ( - "fmt" - - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func ServiceTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "serviceToken", - Aliases: []string{"svc"}, - Short: "Invalidate a service token.", - Long: "Invalidate a service token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - err := imsConfig.InvalidateToken() - if err != nil { - return fmt.Errorf("error invalidating the service token: %w", err) - } - fmt.Println("Service token successfully invalidated.") - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.ServiceToken, "serviceToken", "t", "", "Service token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "s", "", "IMS Client Secret.") - - return cmd -} diff --git a/cmd/validate/access_token.go b/cmd/validate/access_token.go deleted file mode 100644 index b288d43..0000000 --- a/cmd/validate/access_token.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2021 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 validate - -import ( - "fmt" - - "github.com/adobe/imscli/cmd/pretty" - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "accessToken", - Aliases: []string{"acc"}, - Short: "Validate an access token.", - Long: "Validate an access token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - resp, err := imsConfig.ValidateToken() - if err != nil { - return fmt.Errorf("error validating the access token: %w", err) - } - if !resp.Valid { - return fmt.Errorf("invalid token: %v", resp.Info) - } - fmt.Println(pretty.JSON(resp.Info)) - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.AccessToken, "accessToken", "t", "", "Access token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - - return cmd -} diff --git a/cmd/validate/authorization_code.go b/cmd/validate/authorization_code.go deleted file mode 100644 index c910360..0000000 --- a/cmd/validate/authorization_code.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2021 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 validate - -import ( - "fmt" - - "github.com/adobe/imscli/cmd/pretty" - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func AuthzCodeCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "authorizationCode", - Aliases: []string{"authz"}, - Short: "Validate an authorization code.", - Long: "Validate an authorization code.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - resp, err := imsConfig.ValidateToken() - if err != nil { - return fmt.Errorf("error validating the authorization code: %w", err) - } - if !resp.Valid { - return fmt.Errorf("invalid token: %v", resp.Info) - } - fmt.Println(pretty.JSON(resp.Info)) - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.AuthorizationCode, "authorizationCode", "t", "", "Authorization code.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - - return cmd -} diff --git a/cmd/validate/device_token.go b/cmd/validate/device_token.go deleted file mode 100644 index 53807cf..0000000 --- a/cmd/validate/device_token.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2021 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 validate - -import ( - "fmt" - - "github.com/adobe/imscli/cmd/pretty" - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "deviceToken", - Aliases: []string{"dev"}, - Short: "Validate a device token.", - Long: "Validate a device token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - resp, err := imsConfig.ValidateToken() - if err != nil { - return fmt.Errorf("error validating the device token: %w", err) - } - if !resp.Valid { - return fmt.Errorf("invalid token: %v", resp.Info) - } - fmt.Println(pretty.JSON(resp.Info)) - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.DeviceToken, "deviceToken", "t", "", "Device token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - - return cmd -} diff --git a/cmd/validate/refresh_token.go b/cmd/validate/refresh_token.go deleted file mode 100644 index 579bfe4..0000000 --- a/cmd/validate/refresh_token.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2021 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 validate - -import ( - "fmt" - - "github.com/adobe/imscli/cmd/pretty" - "github.com/adobe/imscli/ims" - "github.com/spf13/cobra" -) - -func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { - cmd := &cobra.Command{ - Use: "refreshToken", - Aliases: []string{"ref"}, - Short: "Validate a refresh token.", - Long: "Validate a refresh token.", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.SilenceUsage = true - - - resp, err := imsConfig.ValidateToken() - if err != nil { - return fmt.Errorf("error validating the refresh token: %w", err) - } - if !resp.Valid { - return fmt.Errorf("invalid token: %v", resp.Info) - } - fmt.Println(pretty.JSON(resp.Info)) - return nil - }, - } - - cmd.Flags().StringVarP(&imsConfig.RefreshToken, "refreshToken", "t", "", "Refresh token.") - cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - - return cmd -} diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go new file mode 100644 index 0000000..10e7a18 --- /dev/null +++ b/cmd/validate/validate.go @@ -0,0 +1,82 @@ +// Copyright 2021 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 validate + +import ( + "fmt" + + "github.com/adobe/imscli/cmd/pretty" + "github.com/adobe/imscli/ims" + "github.com/spf13/cobra" +) + +type tokenDef struct { + use string + alias string + label string + flagName string + field *string +} + +func tokenCmd(imsConfig *ims.Config, def tokenDef) *cobra.Command { + cmd := &cobra.Command{ + Use: def.use, + Aliases: []string{def.alias}, + Short: fmt.Sprintf("Validate %s.", def.label), + Long: fmt.Sprintf("Validate %s.", def.label), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + resp, err := imsConfig.ValidateToken() + if err != nil { + return fmt.Errorf("error validating the %s: %w", def.label, err) + } + if !resp.Valid { + return fmt.Errorf("invalid token: %v", resp.Info) + } + fmt.Println(pretty.JSON(resp.Info)) + return nil + }, + } + + cmd.Flags().StringVarP(def.field, def.flagName, "t", "", def.label+".") + cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") + + return cmd +} + +func AccessTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "accessToken", alias: "acc", label: "access token", + flagName: "accessToken", field: &imsConfig.AccessToken, + }) +} + +func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "refreshToken", alias: "ref", label: "refresh token", + flagName: "refreshToken", field: &imsConfig.RefreshToken, + }) +} + +func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "deviceToken", alias: "dev", label: "device token", + flagName: "deviceToken", field: &imsConfig.DeviceToken, + }) +} + +func AuthzCodeCmd(imsConfig *ims.Config) *cobra.Command { + return tokenCmd(imsConfig, tokenDef{ + use: "authorizationCode", alias: "authz", label: "authorization code", + flagName: "authorizationCode", field: &imsConfig.AuthorizationCode, + }) +} From f8af13025a18f85aa1ef37ed62de343b2b5d2d8f Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 15:59:37 +0100 Subject: [PATCH 4/8] Use IMS base URL for DCR registration instead of separate --url flag Remove the RegisterURL config field and --url flag from the dcr register command. The registration endpoint is now derived from the IMS base URL (persistent --url/-U flag) by appending /ims/register. --- cmd/dcr.go | 1 - ims/config.go | 1 - ims/config_test.go | 8 ++++---- ims/register.go | 8 +++++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/dcr.go b/cmd/dcr.go index c794984..7ca9116 100644 --- a/cmd/dcr.go +++ b/cmd/dcr.go @@ -39,7 +39,6 @@ func registerCmd(imsConfig *ims.Config) *cobra.Command { }, } - cmd.Flags().StringVarP(&imsConfig.RegisterURL, "url", "u", "", "Registration endpoint URL.") cmd.Flags().StringVarP(&imsConfig.ClientName, "clientName", "n", "", "Client application name.") cmd.Flags().StringSliceVarP(&imsConfig.RedirectURIs, "redirectURIs", "r", []string{}, "Redirect URIs (comma-separated or multiple flags).") diff --git a/ims/config.go b/ims/config.go index 103e4e2..5c9dd82 100644 --- a/ims/config.go +++ b/ims/config.go @@ -46,7 +46,6 @@ type Config struct { Guid string AuthSrc string DecodeFulfillableData bool - RegisterURL string ClientName string RedirectURIs []string } diff --git a/ims/config_test.go b/ims/config_test.go index 905d6df..ced8041 100644 --- a/ims/config_test.go +++ b/ims/config_test.go @@ -295,10 +295,10 @@ func TestValidateRegisterConfig(t *testing.T) { config Config wantErr string }{ - {name: "valid", config: Config{RegisterURL: "https://example.com/register", ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: ""}, - {name: "missing URL", config: Config{ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing registration endpoint URL"}, - {name: "missing client name", config: Config{RegisterURL: "https://example.com/register", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing client name"}, - {name: "missing redirect URIs", config: Config{RegisterURL: "https://example.com/register", ClientName: "app"}, wantErr: "missing redirect URIs"}, + {name: "valid", config: Config{URL: "https://ims.example.com", ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: ""}, + {name: "missing URL", config: Config{ClientName: "app", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing IMS base URL"}, + {name: "missing client name", config: Config{URL: "https://ims.example.com", RedirectURIs: []string{"https://example.com/cb"}}, wantErr: "missing client name"}, + {name: "missing redirect URIs", config: Config{URL: "https://ims.example.com", ClientName: "app"}, wantErr: "missing redirect URIs"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/ims/register.go b/ims/register.go index 88e94d8..7f70e8c 100644 --- a/ims/register.go +++ b/ims/register.go @@ -15,8 +15,8 @@ type RegisterResponse struct { func (i Config) validateRegisterConfig() error { switch { - case i.RegisterURL == "": - return fmt.Errorf("missing registration endpoint URL parameter") + case i.URL == "": + return fmt.Errorf("missing IMS base URL parameter") case i.ClientName == "": return fmt.Errorf("missing client name parameter") case len(i.RedirectURIs) == 0: @@ -47,7 +47,9 @@ func (i Config) Register() (RegisterResponse, error) { "redirect_uris": %s }`, i.ClientName, redirectURIsJSON)) - req, err := http.NewRequest("POST", i.RegisterURL, payload) + endpoint := strings.TrimRight(i.URL, "/") + "/ims/register" + + req, err := http.NewRequest("POST", endpoint, payload) if err != nil { return RegisterResponse{}, fmt.Errorf("error creating request: %v", err) } From d46aae8bb62ec346099ee512db830efb5c3a8b98 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 16:07:33 +0100 Subject: [PATCH 5/8] Standardize flag shorthands across all commands - -p always means --clientSecret (was -s in authz jwt and invalidate serviceToken) - -s always means --scopes (no longer overloaded) - -a always means --*ApiVersion (no longer overloaded) - --account gets -A, --authorizationCode gets -x, --cascading gets -C, --authSrc gets -A --- cmd/admin/organizations.go | 2 +- cmd/admin/profile.go | 2 +- cmd/authz/jwt.go | 4 ++-- cmd/authz/service.go | 2 +- cmd/invalidate/invalidate.go | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/admin/organizations.go b/cmd/admin/organizations.go index 5318d35..8e2759b 100644 --- a/cmd/admin/organizations.go +++ b/cmd/admin/organizations.go @@ -38,7 +38,7 @@ func OrganizationsCmd(imsConfig *ims.Config) *cobra.Command { }, } cmd.Flags().StringVarP(&imsConfig.Guid, "guid", "g", "", "User ID.") - cmd.Flags().StringVarP(&imsConfig.AuthSrc, "authSrc", "s", "", "Authorization source.") + cmd.Flags().StringVarP(&imsConfig.AuthSrc, "authSrc", "A", "", "Authorization source.") cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS client ID.") cmd.Flags().StringVarP(&imsConfig.ServiceToken, "serviceToken", "t", "", "Service token.") cmd.Flags().StringVarP(&imsConfig.OrgsAPIVersion, "orgsApiVersion", "a", "v5", "Admin organizations API version.") diff --git a/cmd/admin/profile.go b/cmd/admin/profile.go index bcce154..9904ad8 100644 --- a/cmd/admin/profile.go +++ b/cmd/admin/profile.go @@ -36,7 +36,7 @@ func ProfileCmd(imsConfig *ims.Config) *cobra.Command { }, } cmd.Flags().StringVarP(&imsConfig.Guid, "guid", "g", "", "User ID.") - cmd.Flags().StringVarP(&imsConfig.AuthSrc, "authSrc", "s", "", "Authorization source.") + cmd.Flags().StringVarP(&imsConfig.AuthSrc, "authSrc", "A", "", "Authorization source.") cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS client ID.") cmd.Flags().StringVarP(&imsConfig.ServiceToken, "serviceToken", "t", "", "Service token.") cmd.Flags().StringVarP(&imsConfig.ProfileAPIVersion, "profileApiVersion", "a", "v1", "Admin profile API version.") diff --git a/cmd/authz/jwt.go b/cmd/authz/jwt.go index 9ab54d2..90898c7 100644 --- a/cmd/authz/jwt.go +++ b/cmd/authz/jwt.go @@ -36,9 +36,9 @@ func JWTCmd(imsConfig *ims.Config) *cobra.Command { } cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS Client ID.") - cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "s", "", "IMS Client secret.") + cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "p", "", "IMS Client secret.") cmd.Flags().StringVarP(&imsConfig.Organization, "organization", "o", "", "IMS Organization.") - cmd.Flags().StringVarP(&imsConfig.Account, "account", "a", "", "Technical Account ID.") + cmd.Flags().StringVarP(&imsConfig.Account, "account", "A", "", "Technical Account ID.") cmd.Flags().StringVarP(&imsConfig.PrivateKeyPath, "privateKey", "k", "", "Private Key file.") cmd.Flags().StringSliceVarP(&imsConfig.Metascopes, "metascopes", "m", []string{}, "Metascopes to request.") diff --git a/cmd/authz/service.go b/cmd/authz/service.go index 391e58c..c47e272 100644 --- a/cmd/authz/service.go +++ b/cmd/authz/service.go @@ -37,7 +37,7 @@ func ServiceCmd(imsConfig *ims.Config) *cobra.Command { cmd.Flags().StringVarP(&imsConfig.ClientID, "clientID", "c", "", "IMS client ID.") cmd.Flags().StringVarP(&imsConfig.ClientSecret, "clientSecret", "p", "", "IMS client secret.") - cmd.Flags().StringVarP(&imsConfig.AuthorizationCode, "authorizationCode", "a", "", "Permanent authorization code.") + cmd.Flags().StringVarP(&imsConfig.AuthorizationCode, "authorizationCode", "x", "", "Permanent authorization code.") return cmd } diff --git a/cmd/invalidate/invalidate.go b/cmd/invalidate/invalidate.go index 714b166..4a5f82a 100644 --- a/cmd/invalidate/invalidate.go +++ b/cmd/invalidate/invalidate.go @@ -68,7 +68,7 @@ func RefreshTokenCmd(imsConfig *ims.Config) *cobra.Command { flagName: "refreshToken", field: &imsConfig.RefreshToken, successMsg: "Refresh token successfully invalidated.", extraFlags: func(cmd *cobra.Command, c *ims.Config) { - cmd.Flags().BoolVarP(&c.Cascading, "cascading", "a", false, + cmd.Flags().BoolVarP(&c.Cascading, "cascading", "C", false, "Also invalidate all tokens obtained with the refresh token.") }, }) @@ -80,7 +80,7 @@ func DeviceTokenCmd(imsConfig *ims.Config) *cobra.Command { flagName: "deviceToken", field: &imsConfig.DeviceToken, successMsg: "Token invalidated successfully.", extraFlags: func(cmd *cobra.Command, c *ims.Config) { - cmd.Flags().BoolVarP(&c.Cascading, "cascading", "a", false, + cmd.Flags().BoolVarP(&c.Cascading, "cascading", "C", false, "Also invalidate all tokens obtained with the device token.") }, }) @@ -92,7 +92,7 @@ func ServiceTokenCmd(imsConfig *ims.Config) *cobra.Command { flagName: "serviceToken", field: &imsConfig.ServiceToken, successMsg: "Service token successfully invalidated.", extraFlags: func(cmd *cobra.Command, c *ims.Config) { - cmd.Flags().StringVarP(&c.ClientSecret, "clientSecret", "s", "", "IMS Client Secret.") + cmd.Flags().StringVarP(&c.ClientSecret, "clientSecret", "p", "", "IMS Client Secret.") }, }) } From c0d1fca2e94c4e912183fad2c5f2976bdbcc5bfc Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 16:24:39 +0100 Subject: [PATCH 6/8] Split AuthorizeUser into AuthorizeUser and AuthorizeUserPKCE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove PKCE field from Config — it was not user configuration but a command-level decision. The pkce command now calls AuthorizeUserPKCE() directly instead of mutating the shared Config pointer. --- cmd/authz/pkce.go | 3 +-- ims/authz_user.go | 14 +++++++++++--- ims/config.go | 1 - 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cmd/authz/pkce.go b/cmd/authz/pkce.go index 463e286..36d4e76 100644 --- a/cmd/authz/pkce.go +++ b/cmd/authz/pkce.go @@ -27,8 +27,7 @@ func UserPkceCmd(imsConfig *ims.Config) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true - imsConfig.PKCE = true - resp, err := imsConfig.AuthorizeUser() + resp, err := imsConfig.AuthorizeUserPKCE() if err != nil { return fmt.Errorf("error in user authorization: %w", err) } diff --git a/ims/authz_user.go b/ims/authz_user.go index b02cba2..83e252e 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -55,9 +55,17 @@ func (i Config) validateAuthorizeUserConfig() error { return nil } -// AuthorizeUser uses the standard Oauth2 authorization code grant flow. The Oauth2 configuration is -// taken from the Config struct. +// AuthorizeUser uses the standard OAuth2 authorization code grant flow. func (i Config) AuthorizeUser() (string, error) { + return i.authorizeUser(false) +} + +// AuthorizeUserPKCE uses the OAuth2 authorization code grant flow with PKCE. +func (i Config) AuthorizeUserPKCE() (string, error) { + return i.authorizeUser(true) +} + +func (i Config) authorizeUser(pkce bool) (string, error) { // Perform parameter validation err := i.validateAuthorizeUserConfig() if err != nil { @@ -80,7 +88,7 @@ func (i Config) AuthorizeUser() (string, error) { ClientID: i.ClientID, ClientSecret: i.ClientSecret, Scope: i.Scopes, - UsePKCE: i.PKCE, + UsePKCE: pkce, RedirectURI: fmt.Sprintf("http://localhost:%d", port), OnError: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, ` diff --git a/ims/config.go b/ims/config.go index 5c9dd82..371f468 100644 --- a/ims/config.go +++ b/ims/config.go @@ -41,7 +41,6 @@ type Config struct { Cascading bool Token string Port int - PKCE bool FullOutput bool Guid string AuthSrc string From d6badce3faaaf87c26b3f5d798c41ddc1a6d3528 Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 17:13:10 +0100 Subject: [PATCH 7/8] Miscellaneous hardening: zero private key, port validation, struct JSON, URL slash - Zero private key bytes after use in JWT exchange to prevent key material lingering in memory until GC (6.1.7) - Add port validation to validateAuthorizeUserConfig, remove defaultPort fallback so the ims package validates its own parameters (N10) - Replace map[string]interface{} with anonymous struct in refresh command for deterministic JSON field order and better performance (N11) - Trim trailing slash from IMS URL before building JWT claim keys to prevent double-slash in metascope URIs (6.2.10) - Standardize orgs API version error message to match profile style (6.2.12) - Document PersistentPreRunE override risk in README.md (N7) Co-Authored-By: Claude Opus 4.6 --- README.md | 6 ++++++ cmd/refresh.go | 8 ++++---- ims/authz_user.go | 18 ++++++------------ ims/config_test.go | 2 ++ ims/jwt_exchange.go | 9 ++++++++- ims/organizations.go | 2 +- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6b9f030..5652c8a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ imscli authorize user help The complete documentation of the project is available in the [DOCUMENTATION.md](DOCUMENTATION.md) file. +## Development Notes + +### PersistentPreRunE and subcommands + +The root command defines a `PersistentPreRunE` that loads configuration from flags, environment variables, and config files (see `cmd/root.go`). In cobra, if a subcommand defines its own `PersistentPreRunE`, it **overrides** the parent's — the root's `PersistentPreRunE` will not run for that subcommand or its children. If you need to add a `PersistentPreRunE` to a subcommand, you must explicitly call the parent's first. + ## Contributing Contributions are welcomed! Read the [Contributing Guide](CONTRIBUTING.md) for more information. diff --git a/cmd/refresh.go b/cmd/refresh.go index 648b59b..877dfb7 100644 --- a/cmd/refresh.go +++ b/cmd/refresh.go @@ -33,10 +33,10 @@ func refreshCmd(imsConfig *ims.Config) *cobra.Command { return fmt.Errorf("error during the token refresh: %w", err) } if imsConfig.FullOutput { - data := map[string]interface{}{ - "access_token": resp.AccessToken, - "refresh_token": resp.RefreshToken, - } + data := struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + }{resp.AccessToken, resp.RefreshToken} jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("error marshalling full JSON response: %w", err) diff --git a/ims/authz_user.go b/ims/authz_user.go index 83e252e..07685e2 100644 --- a/ims/authz_user.go +++ b/ims/authz_user.go @@ -24,8 +24,6 @@ import ( "github.com/pkg/browser" ) -const defaultPort = 8888 - // Validate that: // - the ims.Config struct has the necessary parameters for AuthorizeUser // - the provided environment exists @@ -42,6 +40,8 @@ func (i Config) validateAuthorizeUserConfig() error { return fmt.Errorf("missing client id parameter") case i.Organization == "": return fmt.Errorf("missing organization parameter") + case i.Port <= 0: + return fmt.Errorf("missing or invalid port parameter") case i.ClientSecret == "": if i.PublicClient { log.Println("all needed parameters verified not empty") @@ -72,12 +72,6 @@ func (i Config) authorizeUser(pkce bool) (string, error) { return "", fmt.Errorf("invalid parameters for login user: %w", err) } - // Use default port if not specified - port := i.Port - if port == 0 { - port = defaultPort - } - c, err := i.newIMSClient() if err != nil { return "", fmt.Errorf("error creating the IMS client: %w", err) @@ -89,7 +83,7 @@ func (i Config) authorizeUser(pkce bool) (string, error) { ClientSecret: i.ClientSecret, Scope: i.Scopes, UsePKCE: pkce, - RedirectURI: fmt.Sprintf("http://localhost:%d", port), + RedirectURI: fmt.Sprintf("http://localhost:%d", i.Port), OnError: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, `

An error occurred

@@ -107,15 +101,15 @@ func (i Config) authorizeUser(pkce bool) (string, error) { return "", fmt.Errorf("create authorization server: %w", err) } - listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", i.Port)) if err != nil { - return "", fmt.Errorf("unable to listen at port %d", port) + return "", fmt.Errorf("unable to listen at port %d", i.Port) } defer listener.Close() log.Println("Local server successfully launched and contacted.") - localUrl := fmt.Sprintf("http://localhost:%d/", port) + localUrl := fmt.Sprintf("http://localhost:%d/", i.Port) // Suppress "Opening in existing browser session." messages from chromium-based // browsers. The CLI token output goes to stdout, so stray browser messages diff --git a/ims/config_test.go b/ims/config_test.go index ced8041..c628844 100644 --- a/ims/config_test.go +++ b/ims/config_test.go @@ -224,6 +224,7 @@ func TestValidateAuthorizeUserConfig(t *testing.T) { ClientSecret: "s", Organization: "org", Scopes: []string{"openid"}, + Port: 8888, } tests := []struct { name string @@ -239,6 +240,7 @@ func TestValidateAuthorizeUserConfig(t *testing.T) { {name: "missing clientID", config: withField(validConfig, func(c *Config) { c.ClientID = "" }), wantErr: "missing client id"}, {name: "missing organization", config: withField(validConfig, func(c *Config) { c.Organization = "" }), wantErr: "missing organization"}, {name: "missing secret non-public", config: withField(validConfig, func(c *Config) { c.ClientSecret = "" }), wantErr: "missing client secret"}, + {name: "missing port", config: withField(validConfig, func(c *Config) { c.Port = 0 }), wantErr: "missing or invalid port"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/ims/jwt_exchange.go b/ims/jwt_exchange.go index a30277b..a1d7ba2 100644 --- a/ims/jwt_exchange.go +++ b/ims/jwt_exchange.go @@ -13,6 +13,7 @@ package ims import ( "fmt" "os" + "strings" "time" "github.com/adobe/ims-go/ims" @@ -29,14 +30,20 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { if err != nil { return TokenInfo{}, fmt.Errorf("error read private key file: %s, %w", i.PrivateKeyPath, err) } + defer func() { + for i := range key { + key[i] = 0 + } + }() // Metascopes are passed as generic claims with the format map[string]interface{} // where the strings are in the form: baseIMSUrl/s/metascope // and the interface{} is 'true' + baseURL := strings.TrimRight(i.URL, "/") claims := make(map[string]interface{}) for _, metascope := range i.Metascopes { - claims[fmt.Sprintf("%s/s/%s", i.URL, metascope)] = true + claims[fmt.Sprintf("%s/s/%s", baseURL, metascope)] = true } r, err := c.ExchangeJWT(&ims.ExchangeJWTRequest{ diff --git a/ims/organizations.go b/ims/organizations.go index 5853c9b..c9fadc4 100644 --- a/ims/organizations.go +++ b/ims/organizations.go @@ -22,7 +22,7 @@ func (i Config) validateGetOrganizationsConfig() error { switch i.OrgsAPIVersion { case "v1", "v2", "v3", "v4", "v5", "v6": default: - return fmt.Errorf("invalid API version parameter, use something like v5") + return fmt.Errorf("invalid API version parameter, latest version is v6") } switch { From 8a68d34ec3cd85ea7950c8bf27b8cff0c51d6e5f Mon Sep 17 00:00:00 2001 From: Jose Antonio Insua Date: Fri, 27 Feb 2026 17:26:42 +0100 Subject: [PATCH 8/8] Remove dead TokenInfo.Expires field and broken calculations The Expires field was set in jwt_exchange.go, exchange.go, and refresh.go but never read by any caller. The calculations were also wrong: duration * duration produces nanoseconds-squared (overflow), and refresh.go used time.Second while the others used time.Millisecond. Remove the field entirely rather than fixing dead code. Co-Authored-By: Claude Opus 4.6 --- ims/config.go | 1 - ims/exchange.go | 2 -- ims/jwt_exchange.go | 1 - ims/refresh.go | 3 +-- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/ims/config.go b/ims/config.go index 371f468..8500eca 100644 --- a/ims/config.go +++ b/ims/config.go @@ -52,7 +52,6 @@ type Config struct { // Access token information type TokenInfo struct { AccessToken string - Expires int //(response.ExpiresIn * time.Millisecond), Valid bool Info string } diff --git a/ims/exchange.go b/ims/exchange.go index 6bd1d43..c1405d4 100644 --- a/ims/exchange.go +++ b/ims/exchange.go @@ -12,7 +12,6 @@ package ims import ( "fmt" - "time" "github.com/adobe/ims-go/ims" ) @@ -62,6 +61,5 @@ func (i Config) ClusterExchange() (TokenInfo, error) { return TokenInfo{ AccessToken: r.AccessToken, - Expires: int(r.ExpiresIn * time.Millisecond), }, nil } diff --git a/ims/jwt_exchange.go b/ims/jwt_exchange.go index a1d7ba2..42a91f4 100644 --- a/ims/jwt_exchange.go +++ b/ims/jwt_exchange.go @@ -61,6 +61,5 @@ func (i Config) AuthorizeJWTExchange() (TokenInfo, error) { return TokenInfo{ AccessToken: r.AccessToken, - Expires: int(r.ExpiresIn * time.Millisecond), }, nil } diff --git a/ims/refresh.go b/ims/refresh.go index 185a258..82ce08e 100644 --- a/ims/refresh.go +++ b/ims/refresh.go @@ -12,8 +12,8 @@ package ims import ( "fmt" + "github.com/adobe/ims-go/ims" - "time" ) func (i Config) validateRefreshConfig() error { @@ -56,7 +56,6 @@ func (i Config) Refresh() (RefreshInfo, error) { return RefreshInfo{ TokenInfo: TokenInfo{ AccessToken: r.AccessToken, - Expires: int(r.ExpiresIn * time.Second), }, RefreshToken: r.RefreshToken, }, nil