Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
109 changes: 109 additions & 0 deletions internal/commands/account/login.go
Original file line number Diff line number Diff line change
@@ -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))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to use config file as fallback? I guess the file should be generic to be able to use it also with e.g. Terraform provider 🤔

}

exec.PushProgressSuccess(msg)

return output.None{}, nil
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should maybe output the token if saving to keyring fails 🤔

}

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
}
90 changes: 90 additions & 0 deletions internal/commands/account/tokenreceiver/server.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
2 changes: 2 additions & 0 deletions internal/commands/all/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down