From 6e5af67ec3897f2623aaffb4ed70e57fb0e8d8a8 Mon Sep 17 00:00:00 2001 From: Joshua Reese Date: Sat, 28 Dec 2024 19:49:26 -0600 Subject: [PATCH] feat: Add the ability to activate a Datum Cloud API Token. Two new auth subcommands have been added: - activate-api-token: Accepts an API token either via stdin or a secure prompt and saves it in the OS keyring. This token will be used to obtain a token compatible with Datum Cloud infrastructure APIs when no service account credentials are located. - logout: Removes an activated token from the OS keyring. Future work will likely come to clean up keyring access when we implement token caching. We may also want to keep an eye on https://github.com/cli/cli/pull/7743 to follow suit if a keyring library switch is made. --- go.mod | 6 +- go.sum | 12 ++++ internal/cmd/auth/activate_api_token.go | 71 +++++++++++++++++++++ internal/cmd/auth/auth.go | 2 + internal/cmd/auth/get_token.go | 15 ++++- internal/cmd/auth/logout.go | 30 +++++++++ internal/datum/api_token_credentials.go | 71 +++++++++++++++++++++ internal/datum/credentials.go | 4 +- internal/keyring/keyring.go | 83 +++++++++++++++++++++++++ 9 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/auth/activate_api_token.go create mode 100644 internal/cmd/auth/logout.go create mode 100644 internal/datum/api_token_credentials.go create mode 100644 internal/keyring/keyring.go 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) +}