From 4657aed3dc0e57081f5cbcca844ef7ae5d144767 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 13 Mar 2026 07:50:15 +0100 Subject: [PATCH 1/3] feat: add oauth2 --- cmd/account/account.go | 2 - cmd/account/account_login.go | 79 +--------- cmd/account/account_logout.go | 7 - cmd/root.go | 20 +-- go.mod | 1 - go.sum | 2 - internal/account-api/client.go | 71 +++++---- internal/account-api/login.go | 84 +++++----- internal/account-api/oauth2.go | 128 +++++++++++++++ internal/account-api/oidc.go | 30 ++++ internal/account-api/producer.go | 13 +- internal/account-api/producer_extension.go | 23 +-- internal/account-api/updates.go | 2 +- internal/config/config.go | 171 --------------------- internal/config/config_test.go | 127 --------------- internal/config/testdata/.shopware-cli.yml | 4 - internal/config/testdata/write-test.yml | 4 - internal/system/browser.go | 27 ++++ 18 files changed, 299 insertions(+), 496 deletions(-) create mode 100644 internal/account-api/oauth2.go create mode 100644 internal/account-api/oidc.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/config/testdata/.shopware-cli.yml delete mode 100644 internal/config/testdata/write-test.yml create mode 100644 internal/system/browser.go diff --git a/cmd/account/account.go b/cmd/account/account.go index 32c309e9..ad4681d3 100644 --- a/cmd/account/account.go +++ b/cmd/account/account.go @@ -4,7 +4,6 @@ import ( "github.com/spf13/cobra" account_api "github.com/shopware/shopware-cli/internal/account-api" - "github.com/shopware/shopware-cli/internal/config" ) var accountRootCmd = &cobra.Command{ @@ -13,7 +12,6 @@ var accountRootCmd = &cobra.Command{ } type ServiceContainer struct { - Conf config.Config AccountClient *account_api.Client } diff --git a/cmd/account/account_login.go b/cmd/account/account_login.go index d6a0ad55..92fde36a 100644 --- a/cmd/account/account_login.go +++ b/cmd/account/account_login.go @@ -3,12 +3,10 @@ package account import ( "fmt" - "charm.land/huh/v2" "github.com/spf13/cobra" accountApi "github.com/shopware/shopware-cli/internal/account-api" - "github.com/shopware/shopware-cli/internal/system" - "github.com/shopware/shopware-cli/logging" + "github.com/shopware/shopware-cli/internal/tui" ) var loginCmd = &cobra.Command{ @@ -16,46 +14,17 @@ var loginCmd = &cobra.Command{ Short: "Login into your Shopware Account", Long: "", RunE: func(cmd *cobra.Command, _ []string) error { - email := services.Conf.GetAccountEmail() - password := services.Conf.GetAccountPassword() - newCredentials := false + tui.PrintBanner() - if len(email) == 0 || len(password) == 0 { - if !system.IsInteractionEnabled(cmd.Context()) { - return fmt.Errorf("credentials missing and interaction is disabled") - } - - var err error - email, password, err = askUserForEmailAndPassword() - if err != nil { - return err - } - - newCredentials = true - - if err := services.Conf.SetAccountEmail(email); err != nil { - return err - } - if err := services.Conf.SetAccountPassword(password); err != nil { - return err - } - } else { - logging.FromContext(cmd.Context()).Infof("Using existing credentials. Use account:logout to logout") - } - - _, err := accountApi.NewApi(cmd.Context(), accountApi.LoginRequest{Email: email, Password: password}) + _, err := accountApi.NewApi(cmd.Context()) if err != nil { - return fmt.Errorf("login failed with error: %w", err) - } - - if newCredentials { - err := services.Conf.Save() - if err != nil { - return fmt.Errorf("cannot save config: %w", err) - } + return err } - logging.FromContext(cmd.Context()).Infof("Login successful. You can now use all account commands") + fmt.Println() + fmt.Println(tui.GreenText.Render(" Login successful!")) + fmt.Println(tui.DimText.Render(" To logout, run: shopware-cli account logout")) + fmt.Println() return nil }, @@ -64,35 +33,3 @@ var loginCmd = &cobra.Command{ func init() { accountRootCmd.AddCommand(loginCmd) } - -func askUserForEmailAndPassword() (string, string, error) { - var email, password string - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Email"). - Validate(emptyValidator). - Value(&email), - huh.NewInput(). - Title("Password"). - EchoMode(huh.EchoModePassword). - Validate(emptyValidator). - Value(&password), - ), - ) - - if err := form.Run(); err != nil { - return "", "", fmt.Errorf("prompt failed %w", err) - } - - return email, password, nil -} - -func emptyValidator(s string) error { - if len(s) == 0 { - return fmt.Errorf("this cannot be empty") - } - - return nil -} diff --git a/cmd/account/account_logout.go b/cmd/account/account_logout.go index e4c3bea4..0ae73f0e 100644 --- a/cmd/account/account_logout.go +++ b/cmd/account/account_logout.go @@ -19,13 +19,6 @@ var logoutCmd = &cobra.Command{ return fmt.Errorf("cannot invalidate token cache: %w", err) } - _ = services.Conf.SetAccountEmail("") - _ = services.Conf.SetAccountPassword("") - - if err := services.Conf.Save(); err != nil { - return fmt.Errorf("cannot write config: %w", err) - } - logging.FromContext(cmd.Context()).Infof("You have been logged out") return nil diff --git a/cmd/root.go b/cmd/root.go index 5b48f59d..cc92a978 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,15 +14,11 @@ import ( "github.com/shopware/shopware-cli/cmd/extension" "github.com/shopware/shopware-cli/cmd/project" accountApi "github.com/shopware/shopware-cli/internal/account-api" - "github.com/shopware/shopware-cli/internal/config" "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/logging" ) -var ( - cfgFile string - version = "dev" -) +var version = "dev" var rootCmd = &cobra.Command{ Use: "shopware-cli", @@ -47,38 +43,26 @@ func Execute(ctx context.Context) { func init() { rootCmd.SilenceErrors = true - cobra.OnInitialize(func() { - _ = config.InitConfig(cfgFile) - }) - cobra.OnFinalize(func() { _ = system.CloseCaches() }) - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.shopware-cli.yaml)") rootCmd.PersistentFlags().Bool("verbose", false, "show debug output") rootCmd.PersistentFlags().BoolP("no-interaction", "n", false, "do not ask any interactive questions") project.Register(rootCmd) extension.Register(rootCmd) account.Register(rootCmd, func(commandName string) (*account.ServiceContainer, error) { - err := config.InitConfig(cfgFile) - if err != nil { - return nil, err - } - conf := config.Config{} if commandName == "login" || commandName == "logout" { return &account.ServiceContainer{ - Conf: conf, AccountClient: nil, }, nil } - client, err := accountApi.NewApi(rootCmd.Context(), conf) + client, err := accountApi.NewApi(rootCmd.Context()) if err != nil { return nil, err } return &account.ServiceContainer{ - Conf: conf, AccountClient: client, }, nil }) diff --git a/go.mod b/go.mod index 122466b0..4db6d76a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/NYTimes/gziphandler v1.1.1 github.com/bep/godartsass/v2 v2.5.0 - github.com/caarlos0/env/v9 v9.0.0 github.com/cespare/xxhash/v2 v2.3.0 github.com/evanw/esbuild v0.27.3 github.com/go-sql-driver/mysql v1.9.3 diff --git a/go.sum b/go.sum index 08eead20..2837f87f 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,6 @@ github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7 github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= -github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= -github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/internal/account-api/client.go b/internal/account-api/client.go index fa2b0642..3b8b2aff 100644 --- a/internal/account-api/client.go +++ b/internal/account-api/client.go @@ -10,6 +10,9 @@ import ( "path/filepath" "time" + "golang.org/x/oauth2" + + "github.com/shopware/shopware-cli/internal/system" "github.com/shopware/shopware-cli/logging" ) @@ -20,11 +23,11 @@ func SetUserAgent(userAgent string) { } type Client struct { - Token token `json:"token"` + Token *oauth2.Token `json:"token,omitempty"` + LegacyToken *legacyToken `json:"legacyToken,omitempty"` } func (c *Client) NewAuthenticatedRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { - logging.FromContext(ctx).Debugf("%s: %s", method, path) r, err := http.NewRequestWithContext(ctx, method, path, body) if err != nil { return nil, err @@ -32,18 +35,27 @@ func (c *Client) NewAuthenticatedRequest(ctx context.Context, method, path strin r.Header.Set("content-type", "application/json") r.Header.Set("accept", "application/json") - r.Header.Set("x-shopware-token", c.Token.Token) + + if c.Token != nil { + c.Token.SetAuthHeader(r) + } else if c.LegacyToken != nil { + r.Header.Set("x-shopware-token", c.LegacyToken.Token) + } + r.Header.Set("user-agent", httpUserAgent) return r, nil } func (*Client) doRequest(request *http.Request) ([]byte, error) { + start := time.Now() resp, err := http.DefaultClient.Do(request) if err != nil { return nil, err } + logging.FromContext(request.Context()).Debugf("%s: %s, took: %s", request.Method, request.URL.String(), time.Since(start)) + data, err := io.ReadAll(resp.Body) if err != nil { _ = resp.Body.Close() @@ -63,37 +75,40 @@ func (*Client) doRequest(request *http.Request) ([]byte, error) { } func (c *Client) isTokenValid() bool { - loc, err := time.LoadLocation(c.Token.Expire.Timezone) - if err != nil { - return false + if c.Token != nil { + return time.Until(c.Token.Expiry) > 60 } - expire, err := time.ParseInLocation("2006-01-02 15:04:05.000000", c.Token.Expire.Date, loc) - if err != nil { - return false + if c.LegacyToken != nil { + loc, err := time.LoadLocation(c.LegacyToken.Expire.Timezone) + if err != nil { + return false + } + + expire, err := time.ParseInLocation("2006-01-02 15:04:05.000000", c.LegacyToken.Expire.Date, loc) + if err != nil { + return false + } + + return expire.UTC().Sub(time.Now().UTC()).Seconds() > 60 } - // When it will be expire in the next minute. Respond with false - return expire.UTC().Sub(time.Now().UTC()).Seconds() > 60 + return false } -const CacheFileName = "shopware-api-client-token.json" - -func getApiTokenCacheFilePath() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - return "", err +func getCacheFileName() string { + if isStaging() { + return "shopware-api-token-staging.json" } + return "shopware-api-token.json" +} - shopwareCacheDir := filepath.Join(cacheDir, "shopware-cli") - return filepath.Join(shopwareCacheDir, CacheFileName), nil +func getApiTokenCacheFilePath() string { + return filepath.Join(system.GetShopwareCliCacheDir(), getCacheFileName()) } func createApiFromTokenCache(ctx context.Context) (*Client, error) { - tokenFilePath, err := getApiTokenCacheFilePath() - if err != nil { - return nil, err - } + tokenFilePath := getApiTokenCacheFilePath() if _, err := os.Stat(tokenFilePath); os.IsNotExist(err) { return nil, err @@ -120,10 +135,7 @@ func createApiFromTokenCache(ctx context.Context) (*Client, error) { } func saveApiTokenToTokenCache(client *Client) error { - tokenFilePath, err := getApiTokenCacheFilePath() - if err != nil { - return err - } + tokenFilePath := getApiTokenCacheFilePath() content, err := json.Marshal(client) if err != nil { @@ -147,10 +159,7 @@ func saveApiTokenToTokenCache(client *Client) error { } func InvalidateTokenCache() error { - tokenFilePath, err := getApiTokenCacheFilePath() - if err != nil { - return err - } + tokenFilePath := getApiTokenCacheFilePath() if _, err := os.Stat(tokenFilePath); os.IsNotExist(err) { return nil diff --git a/internal/account-api/login.go b/internal/account-api/login.go index 8db46c7b..5f312f93 100644 --- a/internal/account-api/login.go +++ b/internal/account-api/login.go @@ -7,45 +7,59 @@ import ( "fmt" "io" "net/http" + "os" "github.com/shopware/shopware-cli/logging" ) -const ApiUrl = "https://api.shopware.com" +const legacyApiUrl = "https://api.shopware.com" -type AccountConfig interface { - GetAccountEmail() string - GetAccountPassword() string -} +func NewApi(ctx context.Context) (*Client, error) { + client, _ := createApiFromTokenCache(ctx) -func NewApi(ctx context.Context, config AccountConfig) (*Client, error) { - errorFormat := "login: %v" + if client != nil && client.isTokenValid() { + return client, nil + } - request := LoginRequest{ - Email: config.GetAccountEmail(), - Password: config.GetAccountPassword(), + // Try legacy username/password auth from environment variables + email := os.Getenv("SHOPWARE_CLI_ACCOUNT_EMAIL") + password := os.Getenv("SHOPWARE_CLI_ACCOUNT_PASSWORD") + + if email != "" && password != "" { + return loginWithCredentials(ctx, email, password) } - client, err := createApiFromTokenCache(ctx) - if err == nil { - return client, nil + // Fall back to interactive OAuth2 login + token, err := InteractiveLogin(ctx) + if err != nil { + return nil, fmt.Errorf("login: %v", err) } - s, err := json.Marshal(request) + client = &Client{Token: token} + + if err := saveApiTokenToTokenCache(client); err != nil { + logging.FromContext(ctx).Errorf(fmt.Sprintf("Cannot save token cache: %v", err)) + } + + return client, nil +} + +func loginWithCredentials(ctx context.Context, email, password string) (*Client, error) { + s, err := json.Marshal(loginRequest{Email: email, Password: password}) if err != nil { - return nil, fmt.Errorf(errorFormat, err) + return nil, fmt.Errorf("login: %v", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ApiUrl+"/accesstokens", bytes.NewBuffer(s)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, legacyApiUrl+"/accesstokens", bytes.NewBuffer(s)) if err != nil { - return nil, fmt.Errorf("create access token request: %w", err) + return nil, fmt.Errorf("login: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, fmt.Errorf(errorFormat, err) + return nil, fmt.Errorf("login: %v", err) } defer func() { @@ -56,7 +70,7 @@ func NewApi(ctx context.Context, config AccountConfig) (*Client, error) { data, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf(errorFormat, err) + return nil, fmt.Errorf("login: %v", err) } if resp.StatusCode != 200 { @@ -72,28 +86,26 @@ func NewApi(ctx context.Context, config AccountConfig) (*Client, error) { return nil, fmt.Errorf("login failed. Check your credentials") } - var token token - if err := json.Unmarshal(data, &token); err != nil { - return nil, fmt.Errorf(errorFormat, err) + var tokenResp legacyToken + if err := json.Unmarshal(data, &tokenResp); err != nil { + return nil, fmt.Errorf("login: %v", err) } - client = &Client{ - Token: token, + client := &Client{ + LegacyToken: &tokenResp, } if err := saveApiTokenToTokenCache(client); err != nil { - logging.FromContext(ctx).Errorf(fmt.Sprintf("Cannot token cache: %v", err)) + logging.FromContext(ctx).Errorf(fmt.Sprintf("Cannot save token cache: %v", err)) } return client, nil } -type token struct { - Token string `json:"token"` - Expire tokenExpire `json:"expire"` - UserAccountID int `json:"userAccountId"` - UserID int `json:"userId"` - LegacyLogin bool `json:"legacyLogin"` +type legacyToken struct { + Token string `json:"token"` + Expire tokenExpire `json:"expire"` + LegacyLogin bool `json:"legacyLogin"` } type tokenExpire struct { @@ -102,15 +114,7 @@ type tokenExpire struct { Timezone string `json:"timezone"` } -type LoginRequest struct { +type loginRequest struct { Email string `json:"shopwareId"` Password string `json:"password"` } - -func (l LoginRequest) GetAccountEmail() string { - return l.Email -} - -func (l LoginRequest) GetAccountPassword() string { - return l.Password -} diff --git a/internal/account-api/oauth2.go b/internal/account-api/oauth2.go new file mode 100644 index 00000000..d5bf1e75 --- /dev/null +++ b/internal/account-api/oauth2.go @@ -0,0 +1,128 @@ +package account_api + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "strings" + + "golang.org/x/oauth2" + + "github.com/shopware/shopware-cli/internal/system" + "github.com/shopware/shopware-cli/internal/tui" + "github.com/shopware/shopware-cli/logging" +) + +func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { + client := &oauth2.Config{ + ClientID: getOIDCClientID(), + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/oauth2/auth", getOIDCEndpoint()), + TokenURL: fmt.Sprintf("%s/oauth2/token", getOIDCEndpoint()), + AuthStyle: oauth2.AuthStyleInParams, + }, + } + + var ( + state = generateRandomState() + pkceVerifier = oauth2.GenerateVerifier() + serverErr = make(chan error) + serverToken = make(chan *oauth2.Token) + ) + + l, err := net.Listen("tcp", "localhost:61472") + if err != nil { + return nil, fmt.Errorf("failed to allocate port for OAuth2 callback handler, try again later: %w", err) + } + + client.RedirectURL = strings.ReplaceAll(fmt.Sprintf("http://%s/callback", l.Addr().String()), "127.0.0.1", "localhost") + + srv := http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(serverErr) + defer close(serverToken) + + ctx := r.Context() + if err := r.ParseForm(); err != nil { + serverErr <- fmt.Errorf("failed to parse form: %w", err) + return + } + if s := r.Form.Get("state"); s != state { + serverErr <- fmt.Errorf("state mismatch: expected %q, got %q", state, s) + return + } + if r.Form.Has("error") { + e, d := r.Form.Get("error"), r.Form.Get("error_description") + serverErr <- fmt.Errorf("upstream error: %s: %s", e, d) + return + } + code := r.Form.Get("code") + if code == "" { + serverErr <- fmt.Errorf("missing code") + return + } + t, err := client.Exchange( + ctx, + code, + oauth2.VerifierOption(pkceVerifier), + ) + if err != nil { + serverErr <- fmt.Errorf("failed OAuth2 token exchange: %w", err) + return + } + serverToken <- t + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

Login successful

You can close this window now.

`)) + }), + } + go func() { _ = srv.Serve(l) }() + defer srv.Close() + + u := client.AuthCodeURL(state, + oauth2.S256ChallengeOption(pkceVerifier), + oauth2.SetAuthURLParam("scope", OIDCScopes), + oauth2.SetAuthURLParam("response_type", "code"), + ) + + fmt.Println(tui.BoldText.Render(" Press Enter to open the login page in your browser...")) + fmt.Println(tui.DimText.Render(fmt.Sprintf(" URL: %s", u))) + fmt.Println() + + enterPressed := make(chan struct{}) + go func() { + _, _ = bufio.NewReader(os.Stdin).ReadBytes('\n') + close(enterPressed) + }() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-enterPressed: + enterPressed = nil // prevent re-triggering + if err := system.OpenURL(u); err != nil { + logging.FromContext(ctx).Infof("Could not open browser automatically. Please open the URL above manually.") + } + fmt.Println(tui.DimText.Render(" Waiting for login to complete...")) + case err := <-serverErr: + return nil, fmt.Errorf("failed to handle OAuth2 callback: %w", err) + case t := <-serverToken: + return t, nil + } + } +} + +func generateRandomState() string { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/internal/account-api/oidc.go b/internal/account-api/oidc.go new file mode 100644 index 00000000..a69508be --- /dev/null +++ b/internal/account-api/oidc.go @@ -0,0 +1,30 @@ +package account_api + +import "os" + +const OIDCScopes = "openid offline_access email profile extension_management_read_write" + +func isStaging() bool { + return os.Getenv("SHOPWARE_CLI_ACCOUNT_STAGING") != "" +} + +func getOIDCEndpoint() string { + if isStaging() { + return "https://auth-api.shopware.in" + } + return "https://auth-api.shopware.com" +} + +func getOIDCClientID() string { + if isStaging() { + return "def413d7-4c4e-439f-8b51-74c352436b2f" + } + return "069d0a55-5237-4706-a5c9-7cb1a45f1e81" +} + +func getApiUrl() string { + if isStaging() { + return "https://next-api.shopware.com" + } + return "https://api.shopware.com" +} diff --git a/internal/account-api/producer.go b/internal/account-api/producer.go index 10ffccd9..f714eee0 100644 --- a/internal/account-api/producer.go +++ b/internal/account-api/producer.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "net/url" "strconv" "strings" @@ -18,7 +19,7 @@ type ProducerEndpoint struct { } func (c *Client) Producer(ctx context.Context) (*ProducerEndpoint, error) { - r, err := c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/integrations/shopwarecli/producers", ApiUrl), nil) + r, err := c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/integrations/shopwarecli/producers", getApiUrl()), nil) if err != nil { return nil, err } @@ -123,7 +124,7 @@ func (e ProducerEndpoint) singleExtensionsByProducer(ctx context.Context, criter return nil, fmt.Errorf("list_extensions: %v", err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/plugins?%s", ApiUrl, form.Encode()), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/plugins?%s", getApiUrl(), form.Encode()), nil) if err != nil { return nil, err } @@ -169,7 +170,7 @@ func (e ProducerEndpoint) GetExtensionById(ctx context.Context, id int) (*Extens errorFormat := "GetExtensionById: %v" // Create it - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/plugins/%d", ApiUrl, id), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/plugins/%d", getApiUrl(), id), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -378,7 +379,7 @@ func (e ProducerEndpoint) UpdateExtension(ctx context.Context, extension *Extens } // Patch the name - r, err := e.c.NewAuthenticatedRequest(ctx, "PUT", fmt.Sprintf("%s/plugins/%d", ApiUrl, extension.Id), bytes.NewBuffer(requestBody)) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPut, fmt.Sprintf("%s/plugins/%d", getApiUrl(), extension.Id), bytes.NewBuffer(requestBody)) if err != nil { return err } @@ -390,7 +391,7 @@ func (e ProducerEndpoint) UpdateExtension(ctx context.Context, extension *Extens func (e ProducerEndpoint) GetSoftwareVersions(ctx context.Context, generation string) (*SoftwareVersionList, error) { errorFormat := "shopware_versions: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/pluginstatics/softwareVersions?filter=[{\"property\":\"pluginGeneration\",\"value\":\"%s\"},{\"property\":\"includeNonPublic\",\"value\":\"1\"}]", ApiUrl, generation), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/pluginstatics/softwareVersions?filter=[{\"property\":\"pluginGeneration\",\"value\":\"%s\"},{\"property\":\"includeNonPublic\",\"value\":\"1\"}]", getApiUrl(), generation), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -510,7 +511,7 @@ type ExtensionGeneralInformation struct { } func (e ProducerEndpoint) GetExtensionGeneralInfo(ctx context.Context) (*ExtensionGeneralInformation, error) { - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/pluginstatics/all", ApiUrl), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/pluginstatics/all", getApiUrl()), nil) if err != nil { return nil, fmt.Errorf("GetExtensionGeneralInfo: %v", err) } diff --git a/internal/account-api/producer_extension.go b/internal/account-api/producer_extension.go index 981b74ae..5f53d631 100644 --- a/internal/account-api/producer_extension.go +++ b/internal/account-api/producer_extension.go @@ -11,6 +11,7 @@ import ( "image/png" "io" "mime/multipart" + "net/http" "os" "path/filepath" @@ -70,7 +71,7 @@ type ExtensionCreate struct { func (e ProducerEndpoint) GetExtensionBinaries(ctx context.Context, producerId int, extensionId int) ([]*ExtensionBinary, error) { errorFormat := "GetExtensionBinaries: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, producerId, extensionId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", getApiUrl(), producerId, extensionId), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -96,7 +97,7 @@ func (e ProducerEndpoint) UpdateExtensionBinaryInfo(ctx context.Context, produce return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "PUT", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d", ApiUrl, producerId, extensionId, update.Id), bytes.NewReader(content)) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPut, fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d", getApiUrl(), producerId, extensionId, update.Id), bytes.NewReader(content)) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -114,7 +115,7 @@ func (e ProducerEndpoint) CreateExtensionBinary(ctx context.Context, producerId, return nil, fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", ApiUrl, producerId, extensionId), bytes.NewReader(createPayload)) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPost, fmt.Sprintf("%s/producers/%d/plugins/%d/binaries", getApiUrl(), producerId, extensionId), bytes.NewReader(createPayload)) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -160,7 +161,7 @@ func (e ProducerEndpoint) UpdateExtensionBinaryFile(ctx context.Context, produce return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d/file", ApiUrl, producerId, extensionId, binaryId), &b) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPost, fmt.Sprintf("%s/producers/%d/plugins/%d/binaries/%d/file", getApiUrl(), producerId, extensionId, binaryId), &b) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -222,7 +223,7 @@ func (e ProducerEndpoint) UpdateExtensionIcon(ctx context.Context, extensionId i return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/plugins/%d/icon", ApiUrl, extensionId), &b) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPost, fmt.Sprintf("%s/plugins/%d/icon", getApiUrl(), extensionId), &b) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -250,7 +251,7 @@ type ExtensionImage struct { func (e ProducerEndpoint) GetExtensionImages(ctx context.Context, extensionId int) ([]*ExtensionImage, error) { errorFormat := "GetExtensionImages: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/plugins/%d/pictures", ApiUrl, extensionId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/plugins/%d/pictures", getApiUrl(), extensionId), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -271,7 +272,7 @@ func (e ProducerEndpoint) GetExtensionImages(ctx context.Context, extensionId in func (e ProducerEndpoint) DeleteExtensionImages(ctx context.Context, extensionId, imageId int) error { errorFormat := "DeleteExtensionImages: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "DELETE", fmt.Sprintf("%s/plugins/%d/pictures/%d", ApiUrl, extensionId, imageId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodDelete, fmt.Sprintf("%s/plugins/%d/pictures/%d", getApiUrl(), extensionId, imageId), nil) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -289,7 +290,7 @@ func (e ProducerEndpoint) UpdateExtensionImage(ctx context.Context, extensionId return fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "PUT", fmt.Sprintf("%s/plugins/%d/pictures/%d", ApiUrl, extensionId, image.Id), bytes.NewReader(content)) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPut, fmt.Sprintf("%s/plugins/%d/pictures/%d", getApiUrl(), extensionId, image.Id), bytes.NewReader(content)) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -323,7 +324,7 @@ func (e ProducerEndpoint) AddExtensionImage(ctx context.Context, extensionId int return nil, fmt.Errorf(errorFormat, err) } - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/plugins/%d/pictures", ApiUrl, extensionId), &b) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPost, fmt.Sprintf("%s/plugins/%d/pictures", getApiUrl(), extensionId), &b) if err != nil { return nil, fmt.Errorf(errorFormat, err) } @@ -347,7 +348,7 @@ func (e ProducerEndpoint) AddExtensionImage(ctx context.Context, extensionId int func (e ProducerEndpoint) TriggerCodeReview(ctx context.Context, extensionId int) error { errorFormat := "TriggerCodeReview: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "POST", fmt.Sprintf("%s/plugins/%d/reviews", ApiUrl, extensionId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodPost, fmt.Sprintf("%s/plugins/%d/reviews", getApiUrl(), extensionId), nil) if err != nil { return fmt.Errorf(errorFormat, err) } @@ -360,7 +361,7 @@ func (e ProducerEndpoint) TriggerCodeReview(ctx context.Context, extensionId int func (e ProducerEndpoint) GetBinaryReviewResults(ctx context.Context, extensionId, binaryId int) ([]BinaryReviewResult, error) { errorFormat := "GetBinaryReviewResults: %v" - r, err := e.c.NewAuthenticatedRequest(ctx, "GET", fmt.Sprintf("%s/plugins/%d/binaries/%d/checkresults", ApiUrl, extensionId, binaryId), nil) + r, err := e.c.NewAuthenticatedRequest(ctx, http.MethodGet, fmt.Sprintf("%s/plugins/%d/binaries/%d/checkresults", getApiUrl(), extensionId, binaryId), nil) if err != nil { return nil, fmt.Errorf(errorFormat, err) } diff --git a/internal/account-api/updates.go b/internal/account-api/updates.go index 59b7fc12..10ada54d 100644 --- a/internal/account-api/updates.go +++ b/internal/account-api/updates.go @@ -30,7 +30,7 @@ type UpdateCheckExtensionCompatibilityStatus struct { } func GetFutureExtensionUpdates(ctx context.Context, currentVersion string, futureVersion string, extensions []UpdateCheckExtension) ([]UpdateCheckExtensionCompatibility, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, ApiUrl+"/swplatform/autoupdate", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, getApiUrl()+"/swplatform/autoupdate", nil) if err != nil { return nil, err } diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 43aca7c8..00000000 --- a/internal/config/config.go +++ /dev/null @@ -1,171 +0,0 @@ -package config - -import ( - "fmt" - "os" - "sync" - - "github.com/caarlos0/env/v9" - "gopkg.in/yaml.v3" -) - -var ( - state *configState - environmentConfigErrorFormat = "could not set config value %s to %q config was loaded from the environment variables" -) - -type configState struct { - mu sync.RWMutex - cfgPath string - inner *configData - loadedFromEnv bool - isReady bool - modified bool -} - -type configData struct { - Account struct { - Email string `env:"SHOPWARE_CLI_ACCOUNT_EMAIL" yaml:"email"` - Password string `env:"SHOPWARE_CLI_ACCOUNT_PASSWORD" yaml:"password"` - } `yaml:"account"` -} - -type Config struct{} - -func init() { - state = &configState{ - mu: sync.RWMutex{}, - cfgPath: "", - inner: defaultConfig(), - } -} - -func defaultConfig() *configData { - config := &configData{} - config.Account.Email = "" - config.Account.Password = "" - return config -} - -func InitConfig(configPath string) error { - state.mu.Lock() - defer state.mu.Unlock() - if state.isReady { - return nil - } - - if len(configPath) > 0 { - state.cfgPath = configPath - } else { - configDir, err := os.UserConfigDir() - if err != nil { - return err - } - - state.cfgPath = fmt.Sprintf("%s/.shopware-cli.yml", configDir) - } - - err := env.Parse(state.inner) - if err != nil { - return err - } - if len(state.inner.Account.Email) > 0 { - state.loadedFromEnv = true - - state.isReady = true - - return nil - } - if _, err := os.Stat(state.cfgPath); os.IsNotExist(err) { - if err := createNewConfig(state.cfgPath); err != nil { - return err - } - } - - content, err := os.ReadFile(state.cfgPath) - if err != nil { - return err - } - - err = yaml.Unmarshal(content, &state.inner) - - if err != nil { - return err - } - - state.isReady = true - return nil -} - -func SaveConfig() error { - state.mu.Lock() - defer state.mu.Unlock() - if !state.modified || state.loadedFromEnv { - return nil - } - - configFile, err := os.OpenFile(state.cfgPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm) - if err != nil { - return err - } - configWriter := yaml.NewEncoder(configFile) - defer func() { - state.modified = false - _ = configWriter.Close() - }() - - return configWriter.Encode(state.inner) -} - -func createNewConfig(path string) error { - f, err := os.Create(path) - if err != nil { - return err - } - - encoder := yaml.NewEncoder(f) - err = encoder.Encode(defaultConfig()) - if err != nil { - return err - } - - return f.Close() -} - -func (Config) GetAccountEmail() string { - state.mu.RLock() - defer state.mu.RUnlock() - return state.inner.Account.Email -} - -func (Config) GetAccountPassword() string { - state.mu.RLock() - defer state.mu.RUnlock() - return state.inner.Account.Password -} - -func (Config) SetAccountEmail(email string) error { - state.mu.Lock() - defer state.mu.Unlock() - if state.loadedFromEnv { - return fmt.Errorf(environmentConfigErrorFormat, "account.email", email) - } - state.modified = true - state.inner.Account.Email = email - return nil -} - -func (Config) SetAccountPassword(password string) error { - state.mu.Lock() - defer state.mu.Unlock() - if state.loadedFromEnv { - return fmt.Errorf(environmentConfigErrorFormat, "account.password", "***") - } - state.modified = true - state.inner.Account.Password = password - return nil -} - -func (Config) Save() error { - return SaveConfig() -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 59921ed7..00000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" -) - -func TestParseEnvConfig(t *testing.T) { - defer resetState() - - testData := struct { - email, password string - }{ - email: "test@test.com", - password: "test123", - } - - t.Setenv("SHOPWARE_CLI_ACCOUNT_EMAIL", testData.email) - t.Setenv("SHOPWARE_CLI_ACCOUNT_PASSWORD", testData.password) - - assert.NoError(t, InitConfig("")) - assert.True(t, state.loadedFromEnv) - - confService := Config{} - assert.Equal(t, testData.email, confService.GetAccountEmail()) - assert.Equal(t, testData.password, confService.GetAccountPassword()) -} - -func TestParseFileConfig(t *testing.T) { - defer resetState() - - testData := struct { - email, password string - }{ - email: "test@test.com", - password: "test123", - } - - cwd, err := os.Getwd() - assert.NoError(t, err) - testConfig := filepath.Join(cwd, "testdata/.shopware-cli.yml") - - assert.NoError(t, InitConfig(testConfig)) - assert.False(t, state.loadedFromEnv) - - confService := Config{} - assert.Equal(t, testData.email, confService.GetAccountEmail()) - assert.Equal(t, testData.password, confService.GetAccountPassword()) - assert.Equal(t, testConfig, state.cfgPath) -} - -func TestSaveConfig(t *testing.T) { - defer resetState() - - testData := struct { - email, password string - }{ - email: "test@new.com", - password: "test", - } - - cwd, err := os.Getwd() - assert.NoError(t, err) - testConfig := filepath.Join(cwd, "testdata/write-test.yml") - configBackup, err := os.ReadFile(testConfig) - assert.NoError(t, err) - defer func() { - assert.NoError(t, os.WriteFile(testConfig, configBackup, os.ModePerm)) - }() - - assert.NoError(t, InitConfig(testConfig)) - - configService := Config{} - - assert.NoError(t, configService.SetAccountEmail(testData.email)) - - assert.NoError(t, configService.SetAccountPassword(testData.password)) - - assert.True(t, state.modified) - - assert.NoError(t, SaveConfig()) - - assert.False(t, state.modified) - - newConfData, err := os.ReadFile(testConfig) - assert.NoError(t, err) - - var newConf configData - assert.NoError(t, yaml.Unmarshal(newConfData, &newConf)) - - assert.Equal(t, testData.email, newConf.Account.Email) - assert.Equal(t, testData.password, newConf.Account.Password) -} - -func TestDontWriteEnvConfig(t *testing.T) { - defer resetState() - - testData := struct { - email, password string - }{ - email: "test@test.com", - password: "test123", - } - - t.Setenv("SHOPWARE_CLI_ACCOUNT_EMAIL", testData.email) - t.Setenv("SHOPWARE_CLI_ACCOUNT_PASSWORD", testData.password) - - assert.NoError(t, InitConfig("")) - assert.True(t, state.loadedFromEnv) - - confService := Config{} - assert.Error(t, confService.SetAccountEmail("test@foo.com")) - assert.Error(t, confService.SetAccountPassword("S3CR3TF4RT3St")) -} - -func resetState() { - state = &configState{ - mu: sync.RWMutex{}, - cfgPath: "", - inner: defaultConfig(), - } -} diff --git a/internal/config/testdata/.shopware-cli.yml b/internal/config/testdata/.shopware-cli.yml deleted file mode 100644 index d2acaa84..00000000 --- a/internal/config/testdata/.shopware-cli.yml +++ /dev/null @@ -1,4 +0,0 @@ -account: - email: test@test.com - password: test123 - company: 456 \ No newline at end of file diff --git a/internal/config/testdata/write-test.yml b/internal/config/testdata/write-test.yml deleted file mode 100644 index 5c387946..00000000 --- a/internal/config/testdata/write-test.yml +++ /dev/null @@ -1,4 +0,0 @@ -account: - email: test@test.com - password: test123 - company: 456 diff --git a/internal/system/browser.go b/internal/system/browser.go new file mode 100644 index 00000000..9229265e --- /dev/null +++ b/internal/system/browser.go @@ -0,0 +1,27 @@ +package system + +import ( + "fmt" + "os/exec" + "runtime" +) + +// OpenURL opens the given URL in the user's default browser. +func OpenURL(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser: %w", err) + } + + return nil +} From bf78b297889e0427161f6fb47ae2c51089e942a9 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 13 Mar 2026 07:52:06 +0100 Subject: [PATCH 2/3] feat(oauth2): update InteractiveLogin to use ListenConfig and pass context to OpenURL --- .claude/worktrees/twinkling-stargazing-neumann | 1 + .golangci.yml | 3 +++ internal/account-api/oauth2.go | 7 ++++--- internal/system/browser.go | 9 +++++---- 4 files changed, 13 insertions(+), 7 deletions(-) create mode 160000 .claude/worktrees/twinkling-stargazing-neumann diff --git a/.claude/worktrees/twinkling-stargazing-neumann b/.claude/worktrees/twinkling-stargazing-neumann new file mode 160000 index 00000000..31cabed2 --- /dev/null +++ b/.claude/worktrees/twinkling-stargazing-neumann @@ -0,0 +1 @@ +Subproject commit 31cabed27d3dbad60a7a152d4c9555e8ab61f8ed diff --git a/.golangci.yml b/.golangci.yml index 4cf62602..02afee5d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -55,6 +55,9 @@ linters: - path: internal\/tui\/* linters: - forbidigo + - path: internal\/account-api\/* + linters: + - forbidigo formatters: enable: diff --git a/internal/account-api/oauth2.go b/internal/account-api/oauth2.go index d5bf1e75..c6ad708b 100644 --- a/internal/account-api/oauth2.go +++ b/internal/account-api/oauth2.go @@ -35,7 +35,8 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { serverToken = make(chan *oauth2.Token) ) - l, err := net.Listen("tcp", "localhost:61472") + lc := net.ListenConfig{} + l, err := lc.Listen(ctx, "tcp", "localhost:61472") if err != nil { return nil, fmt.Errorf("failed to allocate port for OAuth2 callback handler, try again later: %w", err) } @@ -82,7 +83,7 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { }), } go func() { _ = srv.Serve(l) }() - defer srv.Close() + defer func() { _ = srv.Close() }() u := client.AuthCodeURL(state, oauth2.S256ChallengeOption(pkceVerifier), @@ -106,7 +107,7 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { return nil, ctx.Err() case <-enterPressed: enterPressed = nil // prevent re-triggering - if err := system.OpenURL(u); err != nil { + if err := system.OpenURL(ctx, u); err != nil { logging.FromContext(ctx).Infof("Could not open browser automatically. Please open the URL above manually.") } fmt.Println(tui.DimText.Render(" Waiting for login to complete...")) diff --git a/internal/system/browser.go b/internal/system/browser.go index 9229265e..b76ec0ff 100644 --- a/internal/system/browser.go +++ b/internal/system/browser.go @@ -1,22 +1,23 @@ package system import ( + "context" "fmt" "os/exec" "runtime" ) // OpenURL opens the given URL in the user's default browser. -func OpenURL(url string) error { +func OpenURL(ctx context.Context, url string) error { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": - cmd = exec.Command("open", url) + cmd = exec.CommandContext(ctx, "open", url) case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url) default: - cmd = exec.Command("xdg-open", url) + cmd = exec.CommandContext(ctx, "xdg-open", url) } if err := cmd.Start(); err != nil { From 45fd46bcb879f36f758e982806ca0c57f864f38e Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Fri, 13 Mar 2026 08:05:33 +0100 Subject: [PATCH 3/3] refactor(oauth2): improve error handling and prevent zombie processes --- .../worktrees/twinkling-stargazing-neumann | 1 - internal/account-api/client.go | 2 +- internal/account-api/login.go | 4 +- internal/account-api/oauth2.go | 99 ++++++++++--------- internal/system/browser.go | 2 + 5 files changed, 59 insertions(+), 49 deletions(-) delete mode 160000 .claude/worktrees/twinkling-stargazing-neumann diff --git a/.claude/worktrees/twinkling-stargazing-neumann b/.claude/worktrees/twinkling-stargazing-neumann deleted file mode 160000 index 31cabed2..00000000 --- a/.claude/worktrees/twinkling-stargazing-neumann +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 31cabed27d3dbad60a7a152d4c9555e8ab61f8ed diff --git a/internal/account-api/client.go b/internal/account-api/client.go index 3b8b2aff..f082b02b 100644 --- a/internal/account-api/client.go +++ b/internal/account-api/client.go @@ -76,7 +76,7 @@ func (*Client) doRequest(request *http.Request) ([]byte, error) { func (c *Client) isTokenValid() bool { if c.Token != nil { - return time.Until(c.Token.Expiry) > 60 + return time.Until(c.Token.Expiry) > time.Minute } if c.LegacyToken != nil { diff --git a/internal/account-api/login.go b/internal/account-api/login.go index 5f312f93..3f0935c4 100644 --- a/internal/account-api/login.go +++ b/internal/account-api/login.go @@ -12,8 +12,6 @@ import ( "github.com/shopware/shopware-cli/logging" ) -const legacyApiUrl = "https://api.shopware.com" - func NewApi(ctx context.Context) (*Client, error) { client, _ := createApiFromTokenCache(ctx) @@ -50,7 +48,7 @@ func loginWithCredentials(ctx context.Context, email, password string) (*Client, return nil, fmt.Errorf("login: %v", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, legacyApiUrl+"/accesstokens", bytes.NewBuffer(s)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, getApiUrl()+"/accesstokens", bytes.NewBuffer(s)) if err != nil { return nil, fmt.Errorf("login: %w", err) } diff --git a/internal/account-api/oauth2.go b/internal/account-api/oauth2.go index c6ad708b..0f909ce3 100644 --- a/internal/account-api/oauth2.go +++ b/internal/account-api/oauth2.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "strings" + "sync" "golang.org/x/oauth2" @@ -28,11 +29,15 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { }, } + state, err := generateRandomState() + if err != nil { + return nil, fmt.Errorf("failed to generate OAuth2 state: %w", err) + } + var ( - state = generateRandomState() pkceVerifier = oauth2.GenerateVerifier() - serverErr = make(chan error) - serverToken = make(chan *oauth2.Token) + result = make(chan callbackResult, 1) + once sync.Once ) lc := net.ListenConfig{} @@ -45,38 +50,39 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { srv := http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer close(serverErr) - defer close(serverToken) + once.Do(func() { + defer close(result) - ctx := r.Context() - if err := r.ParseForm(); err != nil { - serverErr <- fmt.Errorf("failed to parse form: %w", err) - return - } - if s := r.Form.Get("state"); s != state { - serverErr <- fmt.Errorf("state mismatch: expected %q, got %q", state, s) - return - } - if r.Form.Has("error") { - e, d := r.Form.Get("error"), r.Form.Get("error_description") - serverErr <- fmt.Errorf("upstream error: %s: %s", e, d) - return - } - code := r.Form.Get("code") - if code == "" { - serverErr <- fmt.Errorf("missing code") - return - } - t, err := client.Exchange( - ctx, - code, - oauth2.VerifierOption(pkceVerifier), - ) - if err != nil { - serverErr <- fmt.Errorf("failed OAuth2 token exchange: %w", err) - return - } - serverToken <- t + ctx := r.Context() + if err := r.ParseForm(); err != nil { + result <- callbackResult{err: fmt.Errorf("failed to parse form: %w", err)} + return + } + if s := r.Form.Get("state"); s != state { + result <- callbackResult{err: fmt.Errorf("state mismatch: expected %q, got %q", state, s)} + return + } + if r.Form.Has("error") { + e, d := r.Form.Get("error"), r.Form.Get("error_description") + result <- callbackResult{err: fmt.Errorf("upstream error: %s: %s", e, d)} + return + } + code := r.Form.Get("code") + if code == "" { + result <- callbackResult{err: fmt.Errorf("missing code")} + return + } + t, err := client.Exchange( + ctx, + code, + oauth2.VerifierOption(pkceVerifier), + ) + if err != nil { + result <- callbackResult{err: fmt.Errorf("failed OAuth2 token exchange: %w", err)} + return + } + result <- callbackResult{token: t} + }) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`

Login successful

You can close this window now.

`)) @@ -106,24 +112,29 @@ func InteractiveLogin(ctx context.Context) (*oauth2.Token, error) { case <-ctx.Done(): return nil, ctx.Err() case <-enterPressed: - enterPressed = nil // prevent re-triggering + enterPressed = nil if err := system.OpenURL(ctx, u); err != nil { logging.FromContext(ctx).Infof("Could not open browser automatically. Please open the URL above manually.") } fmt.Println(tui.DimText.Render(" Waiting for login to complete...")) - case err := <-serverErr: - return nil, fmt.Errorf("failed to handle OAuth2 callback: %w", err) - case t := <-serverToken: - return t, nil + case r := <-result: + if r.err != nil { + return nil, fmt.Errorf("failed to handle OAuth2 callback: %w", r.err) + } + return r.token, nil } } } -func generateRandomState() string { +type callbackResult struct { + token *oauth2.Token + err error +} + +func generateRandomState() (string, error) { b := make([]byte, 16) - _, err := rand.Read(b) - if err != nil { - panic(err) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random state: %w", err) } - return hex.EncodeToString(b) + return hex.EncodeToString(b), nil } diff --git a/internal/system/browser.go b/internal/system/browser.go index b76ec0ff..cbf5bb2b 100644 --- a/internal/system/browser.go +++ b/internal/system/browser.go @@ -24,5 +24,7 @@ func OpenURL(ctx context.Context, url string) error { return fmt.Errorf("failed to open browser: %w", err) } + go func() { _ = cmd.Wait() }() + return nil }