From c410d108c5fd777c17e4c4ac8a7c1af7040f275c Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Wed, 26 Feb 2025 13:46:48 +0200 Subject: [PATCH 1/4] feat(account): add login command for saving token to system keyring --- CHANGELOG.md | 1 + internal/commands/account/login.go | 70 ++++++++++++++++++++++++++++++ internal/commands/all/all.go | 1 + internal/config/config.go | 4 ++ 4 files changed, 76 insertions(+) create mode 100644 internal/commands/account/login.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f136b47..14bf92076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Experimental support for reading password or token from system keyring. +- Experimental support from saving token to system keyring with `upctl account login --with-token`. ## [3.15.0] - 2025-02-26 diff --git a/internal/commands/account/login.go b/internal/commands/account/login.go new file mode 100644 index 000000000..0d5831f76 --- /dev/null +++ b/internal/commands/account/login.go @@ -0,0 +1,70 @@ +package account + +import ( + "bufio" + "fmt" + "strings" + + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/spf13/pflag" +) + +// LoginCommand creates the "account login" command +func LoginCommand() commands.Command { + return &loginCommand{ + BaseCommand: commands.New( + "login", + "Configure an authentication token to the system keyring.", + "upctl account login --with-token", + ), + } +} + +type loginCommand struct { + *commands.BaseCommand + + withToken config.OptionalBoolean +} + +// InitCommand implements Command.InitCommand +func (s *loginCommand) InitCommand() { + fs := &pflag.FlagSet{} + config.AddToggleFlag(fs, &s.withToken, "with-token", false, "Read token from standard input.") + s.AddFlags(fs) + + // Require the with-token flag until we support using browser to authenticate. + commands.Must(s.Cobra().MarkFlagRequired("with-token")) +} + +// DoesNotUseServices implements commands.OfflineCommand as this command does not use services +func (s *loginCommand) DoesNotUseServices() {} + +// ExecuteWithoutArguments implements commands.NoArgumentCommand +func (s *loginCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { + if s.withToken.Value() { + return s.executeWithToken(exec) + } + + return output.None{}, nil +} + +func (s *loginCommand) executeWithToken(exec commands.Executor) (output.Output, error) { + in := bufio.NewReader(s.Cobra().InOrStdin()) + token, err := in.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read token from standard input: %w", err) + } + + msg := "Saving provided token to the system keyring." + exec.PushProgressStarted(msg) + err = config.SaveToken(strings.TrimSpace(token)) + if err != nil { + return commands.HandleError(exec, msg, err) + } + + exec.PushProgressSuccess(msg) + + return output.None{}, nil +} diff --git a/internal/commands/all/all.go b/internal/commands/all/all.go index 557f69fc4..08e951b5a 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -115,6 +115,7 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) { // Account accountCommand := commands.BuildCommand(account.BaseAccountCommand(), rootCmd, conf) + commands.BuildCommand(account.LoginCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.ShowCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.ListCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.DeleteCommand(), accountCommand.Cobra(), conf) diff --git a/internal/config/config.go b/internal/config/config.go index c41db54b1..6a4e1b559 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -249,6 +249,10 @@ func GetVersion() string { return version } +func SaveToken(token string) error { + return keyring.Set(keyringServiceName, "", token) +} + func getVersion() string { // Version was overridden during the build if Version != "dev" { From 1646e5964502696a1534bf99b243850c4fa3939c Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Thu, 27 Feb 2025 11:32:37 +0200 Subject: [PATCH 2/4] chore(account): mark login command as experimental --- internal/commands/account/login.go | 4 ++-- internal/config/config.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/commands/account/login.go b/internal/commands/account/login.go index 0d5831f76..d1e560059 100644 --- a/internal/commands/account/login.go +++ b/internal/commands/account/login.go @@ -16,7 +16,7 @@ func LoginCommand() commands.Command { return &loginCommand{ BaseCommand: commands.New( "login", - "Configure an authentication token to the system keyring.", + "Configure an authentication token to the system keyring (EXPERIMENTAL) ", "upctl account login --with-token", ), } @@ -59,7 +59,7 @@ func (s *loginCommand) executeWithToken(exec commands.Executor) (output.Output, msg := "Saving provided token to the system keyring." exec.PushProgressStarted(msg) - err = config.SaveToken(strings.TrimSpace(token)) + err = config.SaveTokenToKeyring(strings.TrimSpace(token)) if err != nil { return commands.HandleError(exec, msg, err) } diff --git a/internal/config/config.go b/internal/config/config.go index 6a4e1b559..9a6459ea4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -249,7 +249,7 @@ func GetVersion() string { return version } -func SaveToken(token string) error { +func SaveTokenToKeyring(token string) error { return keyring.Set(keyringServiceName, "", token) } From 18ea14d12caee2f1b0c3462fdae05377340fa900 Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Tue, 11 Feb 2025 23:13:35 +0200 Subject: [PATCH 3/4] feat(account): support configuring credentials with browser login --- go.mod | 1 + go.sum | 2 + internal/commands/account/login.go | 42 +++++++++ .../commands/account/tokenreceiver/server.go | 85 +++++++++++++++++++ internal/commands/all/all.go | 1 + 5 files changed, 131 insertions(+) create mode 100644 internal/commands/account/tokenreceiver/server.go diff --git a/go.mod b/go.mod index a022b559d..a9f2866a0 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/UpCloudLtd/upcloud-go-api/v8 v8.16.0 github.com/adrg/xdg v0.3.2 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d + github.com/cli/browser v1.3.0 github.com/gemalto/flume v0.12.0 github.com/jedib0t/go-pretty/v6 v6.4.9 github.com/m7shapan/cidr v0.0.0-20200427124835-7eba0889a5d2 diff --git a/go.sum b/go.sum index 7c73a3bf6..5c8c79d52 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= diff --git a/internal/commands/account/login.go b/internal/commands/account/login.go index d1e560059..aa6318018 100644 --- a/internal/commands/account/login.go +++ b/internal/commands/account/login.go @@ -5,7 +5,9 @@ import ( "fmt" "strings" + "github.com/UpCloudLtd/progress/messages" "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/account/tokenreceiver" "github.com/UpCloudLtd/upcloud-cli/v3/internal/config" "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" "github.com/spf13/pflag" @@ -47,6 +49,46 @@ func (s *loginCommand) ExecuteWithoutArguments(exec commands.Executor) (output.O return s.executeWithToken(exec) } + return s.execute(exec) +} + +func (s *loginCommand) execute(exec commands.Executor) (output.Output, error) { + msg := "Waiting to receive token from browser." + exec.PushProgressStarted(msg) + + receiver := tokenreceiver.New() + err := receiver.Start() + if err != nil { + return commands.HandleError(exec, msg, err) + } + + err = receiver.OpenBrowser() + if err != nil { + url := receiver.GetLoginURL() + exec.PushProgressUpdate(messages.Update{ + Message: "Failed to open browser.", + Status: messages.MessageStatusError, + Details: fmt.Sprintf("Please open a browser and navigate to %s to continue with the login.", url), + }) + } + + token, err := receiver.Wait(exec.Context()) + if err != nil { + return commands.HandleError(exec, msg, err) + } + + exec.PushProgressUpdate(messages.Update{ + Key: msg, + Message: "Saving created token to the system keyring.", + }) + + err = config.SaveTokenToKeyring(token) + if err != nil { + return commands.HandleError(exec, msg, fmt.Errorf("failed to save token to keyring: %w", err)) + } + + exec.PushProgressSuccess(msg) + return output.None{}, nil } diff --git a/internal/commands/account/tokenreceiver/server.go b/internal/commands/account/tokenreceiver/server.go new file mode 100644 index 000000000..b1f7b8327 --- /dev/null +++ b/internal/commands/account/tokenreceiver/server.go @@ -0,0 +1,85 @@ +package tokenreceiver + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/cli/browser" +) + +type ReceiverServer struct { + server *http.Server + token string + port string +} + +func New() *ReceiverServer { + return &ReceiverServer{} +} + +func getPort(listener net.Listener) string { + _, port, _ := net.SplitHostPort(listener.Addr().String()) + return port +} + +func getURL(target string) string { + return fmt.Sprintf("http://localhost:3000/account/upctl-login/%s", target) +} + +func (s *ReceiverServer) GetLoginURL() string { + return getURL(s.port) +} + +func (s *ReceiverServer) Start() error { + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + token := req.URL.Query().Get("token") + if token == "" { + http.Redirect(w, req, getURL("error"), http.StatusSeeOther) + return + } + s.token = token + http.Redirect(w, req, getURL("success"), http.StatusSeeOther) + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("failed to create receiver server: %w", err) + } + + go func() { + defer listener.Close() + s.server = &http.Server{ + Handler: handler, + ReadHeaderTimeout: time.Second, + } + _ = s.server.Serve(listener) + }() + s.port = getPort(listener) + return nil +} + +func (s *ReceiverServer) OpenBrowser() error { + return browser.OpenURL(s.GetLoginURL()) +} + +func (s *ReceiverServer) Wait(ctx context.Context) (string, error) { + ticker := time.NewTicker(time.Second * 2) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + _ = s.server.Shutdown(context.TODO()) + return "", ctx.Err() + case <-ticker.C: + if s.token != "" { + _ = s.server.Shutdown(context.TODO()) + return s.token, nil + } + } + } +} diff --git a/internal/commands/all/all.go b/internal/commands/all/all.go index 08e951b5a..2002a6fe0 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -117,6 +117,7 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) { accountCommand := commands.BuildCommand(account.BaseAccountCommand(), rootCmd, conf) commands.BuildCommand(account.LoginCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.ShowCommand(), accountCommand.Cobra(), conf) + commands.BuildCommand(account.LoginCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.ListCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.DeleteCommand(), accountCommand.Cobra(), conf) From ad22c5c478baba2c0a29c8847bc0ef70366e8756 Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Thu, 27 Feb 2025 18:06:45 +0200 Subject: [PATCH 4/4] refactor(account): use fetch instead of redirects to save token --- go.mod | 1 + go.sum | 2 ++ internal/commands/account/login.go | 3 --- internal/commands/account/tokenreceiver/server.go | 13 +++++++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index a9f2866a0..9e91cd8bb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.4.9 github.com/m7shapan/cidr v0.0.0-20200427124835-7eba0889a5d2 github.com/mattn/go-isatty v0.0.16 + github.com/rs/cors v1.11.1 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 diff --git a/go.sum b/go.sum index 5c8c79d52..4c0b1a5cc 100644 --- a/go.sum +++ b/go.sum @@ -239,6 +239,8 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= diff --git a/internal/commands/account/login.go b/internal/commands/account/login.go index aa6318018..257b0b116 100644 --- a/internal/commands/account/login.go +++ b/internal/commands/account/login.go @@ -35,9 +35,6 @@ func (s *loginCommand) InitCommand() { fs := &pflag.FlagSet{} config.AddToggleFlag(fs, &s.withToken, "with-token", false, "Read token from standard input.") s.AddFlags(fs) - - // Require the with-token flag until we support using browser to authenticate. - commands.Must(s.Cobra().MarkFlagRequired("with-token")) } // DoesNotUseServices implements commands.OfflineCommand as this command does not use services diff --git a/internal/commands/account/tokenreceiver/server.go b/internal/commands/account/tokenreceiver/server.go index b1f7b8327..9e4df259d 100644 --- a/internal/commands/account/tokenreceiver/server.go +++ b/internal/commands/account/tokenreceiver/server.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cli/browser" + "github.com/rs/cors" ) type ReceiverServer struct { @@ -34,17 +35,21 @@ func (s *ReceiverServer) GetLoginURL() string { } func (s *ReceiverServer) Start() error { - handler := http.NewServeMux() - handler.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc("GET /ping", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("POST /callback", func(w http.ResponseWriter, req *http.Request) { token := req.URL.Query().Get("token") if token == "" { - http.Redirect(w, req, getURL("error"), http.StatusSeeOther) + w.WriteHeader(http.StatusBadRequest) return } s.token = token - http.Redirect(w, req, getURL("success"), http.StatusSeeOther) + w.WriteHeader(http.StatusNoContent) }) + handler := cors.Default().Handler(mux) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("failed to create receiver server: %w", err)