diff --git a/CLAUDE.md b/CLAUDE.md index ef39ad6..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 @@ -46,22 +53,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 @@ -94,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` @@ -114,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` @@ -155,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 | --- @@ -178,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) | --- @@ -231,6 +189,18 @@ 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) +- **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) @@ -239,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/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/admin/organizations.go b/cmd/admin/organizations.go index bd99b61..8e2759b 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 { @@ -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 02db659..9904ad8 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 { @@ -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/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..90898c7 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 { @@ -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/pkce.go b/cmd/authz/pkce.go index 28dad05..36d4e76 100644 --- a/cmd/authz/pkce.go +++ b/cmd/authz/pkce.go @@ -26,9 +26,8 @@ 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() + + resp, err := imsConfig.AuthorizeUserPKCE() if err != nil { return fmt.Errorf("error in user authorization: %w", err) } diff --git a/cmd/authz/service.go b/cmd/authz/service.go index 4a592b5..c47e272 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 { @@ -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/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..7ca9116 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 { @@ -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/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 deleted file mode 100644 index a8fdee2..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 - cmd.SilenceErrors = 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 b5f000e..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 - cmd.SilenceErrors = 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..4a5f82a --- /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", "C", 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", "C", 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", "p", "", "IMS Client Secret.") + }, + }) +} diff --git a/cmd/invalidate/refresh_token.go b/cmd/invalidate/refresh_token.go deleted file mode 100644 index 90f80d1..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 - cmd.SilenceErrors = 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 a6784ab..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 - cmd.SilenceErrors = 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/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..877dfb7 100644 --- a/cmd/refresh.go +++ b/cmd/refresh.go @@ -26,17 +26,17 @@ 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 { 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/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 deleted file mode 100644 index 70f4428..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 - cmd.SilenceErrors = 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 66337bc..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 - cmd.SilenceErrors = 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 7209bfc..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 - cmd.SilenceErrors = 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 e149111..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 - cmd.SilenceErrors = 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, + }) +} 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..a4f26c9 100644 --- a/ims/authz_client.go +++ b/ims/authz_client.go @@ -16,20 +16,31 @@ 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) { - httpClient, err := i.httpClient() - if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) + if err := i.validateAuthorizeClientCredentialsConfig(); err != nil { + return "", fmt.Errorf("invalid parameters for client credentials authorization: %w", err) } - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) + c, err := i.newIMSClient() 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..3cc24db 100644 --- a/ims/authz_service.go +++ b/ims/authz_service.go @@ -16,20 +16,31 @@ 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) { - httpClient, err := i.httpClient() - if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) + if err := i.validateAuthorizeServiceConfig(); err != nil { + return "", fmt.Errorf("invalid parameters for service authorization: %w", err) } - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) + c, err := i.newIMSClient() 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..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") @@ -55,32 +55,26 @@ 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 { return "", fmt.Errorf("invalid parameters for login user: %w", err) } - // Use default port if not specified - port := i.Port - if port == 0 { - port = defaultPort - } - - httpClient, err := i.httpClient() - if err != nil { - return "", fmt.Errorf("error creating the HTTP Client: %w", err) - } - - c, err := ims.NewClient(&ims.ClientConfig{ - URL: i.URL, - Client: httpClient, - }) + c, err := i.newIMSClient() 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{ @@ -88,8 +82,8 @@ func (i Config) AuthorizeUser() (string, error) { ClientID: i.ClientID, ClientSecret: i.ClientSecret, Scope: i.Scopes, - UsePKCE: i.PKCE, - RedirectURI: fmt.Sprintf("http://localhost:%d", port), + UsePKCE: pkce, + RedirectURI: fmt.Sprintf("http://localhost:%d", i.Port), OnError: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, `