Skip to content
Merged
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
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -57,13 +65,17 @@ 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=
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=
Expand Down
71 changes: 71 additions & 0 deletions internal/cmd/auth/activate_api_token.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ var Command = &cobra.Command{

func init() {
Command.AddCommand(
activateAPITokenCmd(),
getTokenCmd(),
logoutCmd(),
updateKubeconfigCmd(),
)
}
15 changes: 14 additions & 1 deletion internal/cmd/auth/get_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,37 @@ 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"
clientauthv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1"
)

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")
Expand Down Expand Up @@ -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
}
30 changes: 30 additions & 0 deletions internal/cmd/auth/logout.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions internal/datum/api_token_credentials.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
4 changes: 3 additions & 1 deletion internal/datum/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down
Loading
Loading