diff --git a/go.mod b/go.mod index f9f65d8..1dd51df 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,20 @@ go 1.23.1 require ( github.com/spf13/cobra v1.8.1 + github.com/zalando/go-keyring v0.2.6 golang.org/x/oauth2 v0.24.0 + golang.org/x/term v0.27.0 k8s.io/apimachinery v0.32.0 k8s.io/client-go v0.32.0 ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -24,7 +29,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect golang.org/x/net v0.32.0 // indirect golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.8.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index c8e495c..e519e19 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -15,6 +19,8 @@ github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2Kv github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -27,6 +33,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -57,6 +65,8 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -64,6 +74,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/internal/cmd/auth/activate_api_token.go b/internal/cmd/auth/activate_api_token.go new file mode 100644 index 0000000..3b032c3 --- /dev/null +++ b/internal/cmd/auth/activate_api_token.go @@ -0,0 +1,71 @@ +package auth + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "go.datum.net/datumctl/internal/datum" + "go.datum.net/datumctl/internal/keyring" +) + +func activateAPITokenCmd() *cobra.Command { + var hostname string + var withToken bool + + cmd := &cobra.Command{ + Use: "activate-api-token", + Short: "Authenticate to Datum Cloud with an API token and store in keyring", + RunE: func(cmd *cobra.Command, _ []string) error { + + var token string + + if withToken { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read token from standard input: %w", err) + } + token = strings.TrimSpace(string(b)) + } else { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return errors.New("cannot prompt for token without a TTY; use --with-token to read from stdin") + } + + fmt.Fprint(os.Stderr, "Enter API token: ") + rawToken, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return fmt.Errorf("failed to read token from prompt: %w", err) + } + + fmt.Fprintln(os.Stderr, "") + token = strings.TrimSpace(string(rawToken)) + } + + // Make sure the token is valid + tokenSource := datum.NewAPITokenSource(token, hostname) + _, err := tokenSource.Token() + if err != nil { + fmt.Printf("failed to verify API token for %s: %s\n", hostname, err) + os.Exit(1) + } + + if err := keyring.Set("datumctl", "datumctl", token); err != nil { + return fmt.Errorf("failed to store token in keyring: %w", err) + } + + fmt.Println("API token verified and stored in keyring") + + return nil + }, + } + + cmd.Flags().BoolVar(&withToken, "with-token", false, "Read API token from standard input") + cmd.Flags().StringVar(&hostname, "hostname", "api.datum.net", "The hostname of the Datum Cloud instance to authenticate with") + + return cmd +} diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 6ef307b..948974c 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -9,7 +9,9 @@ var Command = &cobra.Command{ func init() { Command.AddCommand( + activateAPITokenCmd(), getTokenCmd(), + logoutCmd(), updateKubeconfigCmd(), ) } diff --git a/internal/cmd/auth/get_token.go b/internal/cmd/auth/get_token.go index affd98e..600ff5d 100644 --- a/internal/cmd/auth/get_token.go +++ b/internal/cmd/auth/get_token.go @@ -2,11 +2,13 @@ package auth import ( "encoding/json" + "errors" "fmt" "os" "slices" "go.datum.net/datumctl/internal/datum" + "go.datum.net/datumctl/internal/keyring" "github.com/spf13/cobra" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,13 +16,23 @@ import ( ) func getTokenCmd() *cobra.Command { + var hostname string + cmd := &cobra.Command{ Use: "get-token", Short: "Retrieve access tokens for the Datum Cloud API", RunE: func(cmd *cobra.Command, _ []string) error { tokenSource, err := datum.DefaultTokenSource(cmd.Context()) if err != nil { - return err + if errors.Is(err, datum.ErrDefaultCredentialsNotFound) { + token, err := keyring.Get("datumctl", "datumctl") + if err != nil { + return fmt.Errorf("failed to get token from keyring: %w", err) + } + tokenSource = datum.NewAPITokenSource(token, hostname) + } else { + return err + } } outputFormat, err := cmd.Flags().GetString("output") @@ -65,6 +77,7 @@ func getTokenCmd() *cobra.Command { } cmd.Flags().String("output", "token", "Output format of the token. Supports 'token' or 'client.authentication.k8s.io/v1'.") + cmd.Flags().StringVar(&hostname, "hostname", "api.datum.net", "The hostname of the Datum Cloud instance to authenticate with") return cmd } diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go new file mode 100644 index 0000000..6142e69 --- /dev/null +++ b/internal/cmd/auth/logout.go @@ -0,0 +1,30 @@ +package auth + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/keyring" +) + +func logoutCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Remove authentication for Datum Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + if err := keyring.Delete("datumctl", "datumctl"); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("no API token to remove from keyring") + } else { + return fmt.Errorf("failed to delete token from keyring: %w", err) + } + } + fmt.Println("API token removed from keyring") + return nil + }, + } + + return cmd +} diff --git a/internal/datum/api_token_credentials.go b/internal/datum/api_token_credentials.go new file mode 100644 index 0000000..f6b4947 --- /dev/null +++ b/internal/datum/api_token_credentials.go @@ -0,0 +1,71 @@ +package datum + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + "golang.org/x/oauth2" +) + +type apiTokenSource struct { + APIToken string + + Hostname string +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + + Message string `json:"message"` +} + +func (s *apiTokenSource) Token() (*oauth2.Token, error) { + client := http.DefaultClient + + url := fmt.Sprintf("https://%s/oauth/token/exchange", s.Hostname) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+s.APIToken) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || (resp.StatusCode >= 300 && resp.StatusCode < 400) { + return nil, fmt.Errorf("unexpected status code %d from token endpoint", resp.StatusCode) + } + + var r tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, fmt.Errorf("failed to decode JSON response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, errors.New(r.Message) + } + + if r.AccessToken == "" { + return nil, fmt.Errorf("no access_token field returned by %s", url) + } + + return &oauth2.Token{ + AccessToken: r.AccessToken, + TokenType: "Bearer", + }, nil +} + +func NewAPITokenSource(token, hostname string) oauth2.TokenSource { + return &apiTokenSource{ + APIToken: token, + Hostname: hostname, + } +} diff --git a/internal/datum/credentials.go b/internal/datum/credentials.go index 74fd945..ff2417f 100644 --- a/internal/datum/credentials.go +++ b/internal/datum/credentials.go @@ -12,6 +12,8 @@ import ( "golang.org/x/oauth2/jwt" ) +var ErrDefaultCredentialsNotFound = fmt.Errorf("could not find default application credentials") + func DefaultTokenSource(ctx context.Context) (oauth2.TokenSource, error) { var creds *credentialsFile var err error @@ -40,7 +42,7 @@ func DefaultTokenSource(ctx context.Context) (oauth2.TokenSource, error) { } if creds == nil { - return nil, fmt.Errorf("could not find default application credentials") + return nil, ErrDefaultCredentialsNotFound } ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 0000000..2f6bbd0 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,83 @@ +// Package keyring is a simple wrapper that adds timeouts to the zalando/go-keyring package. +// Taken from: https://github.com/cli/cli/blob/6c5145166003ac6fb952c5c591a6f3bdeea10465/internal/keyring/keyring.go +package keyring + +import ( + "errors" + "time" + + keyring "github.com/zalando/go-keyring" +) + +var ErrNotFound = keyring.ErrNotFound + +type TimeoutError struct { + message string +} + +func (e *TimeoutError) Error() string { + return e.message +} + +// Set secret in keyring for user. +func Set(service, user, secret string) error { + ch := make(chan error, 1) + go func() { + defer close(ch) + ch <- keyring.Set(service, user, secret) + }() + select { + case err := <-ch: + return err + case <-time.After(3 * time.Second): + return &TimeoutError{"timeout while trying to set secret in keyring"} + } +} + +// Get secret from keyring given service and user name. +func Get(service, user string) (string, error) { + ch := make(chan struct { + val string + err error + }, 1) + go func() { + defer close(ch) + val, err := keyring.Get(service, user) + ch <- struct { + val string + err error + }{val, err} + }() + select { + case res := <-ch: + if errors.Is(res.err, keyring.ErrNotFound) { + return "", ErrNotFound + } + return res.val, res.err + case <-time.After(3 * time.Second): + return "", &TimeoutError{"timeout while trying to get secret from keyring"} + } +} + +// Delete secret from keyring. +func Delete(service, user string) error { + ch := make(chan error, 1) + go func() { + defer close(ch) + ch <- keyring.Delete(service, user) + }() + select { + case err := <-ch: + return err + case <-time.After(3 * time.Second): + return &TimeoutError{"timeout while trying to delete secret from keyring"} + } +} + +func MockInit() { + keyring.MockInit() +} + +func MockInitWithError(err error) { + keyring.MockInitWithError(err) +}