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/go.mod b/go.mod index a022b559d..9e91cd8bb 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,12 @@ 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 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 7c73a3bf6..4c0b1a5cc 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= @@ -237,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 new file mode 100644 index 000000000..257b0b116 --- /dev/null +++ b/internal/commands/account/login.go @@ -0,0 +1,109 @@ +package account + +import ( + "bufio" + "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" +) + +// LoginCommand creates the "account login" command +func LoginCommand() commands.Command { + return &loginCommand{ + BaseCommand: commands.New( + "login", + "Configure an authentication token to the system keyring (EXPERIMENTAL) ", + "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) +} + +// 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 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 +} + +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.SaveTokenToKeyring(strings.TrimSpace(token)) + if err != nil { + return commands.HandleError(exec, msg, 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..9e4df259d --- /dev/null +++ b/internal/commands/account/tokenreceiver/server.go @@ -0,0 +1,90 @@ +package tokenreceiver + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/cli/browser" + "github.com/rs/cors" +) + +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 { + 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 == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + s.token = token + 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) + } + + 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 557f69fc4..2002a6fe0 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -115,7 +115,9 @@ 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.LoginCommand(), 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..9a6459ea4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -249,6 +249,10 @@ func GetVersion() string { return version } +func SaveTokenToKeyring(token string) error { + return keyring.Set(keyringServiceName, "", token) +} + func getVersion() string { // Version was overridden during the build if Version != "dev" {