diff --git a/go.mod b/go.mod index a3c432ba..9ce7bae9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/debricked/cli go 1.20 require ( + github.com/becheran/wildmatch-go v1.0.0 github.com/bmatcuk/doublestar/v4 v4.6.0 github.com/chelnak/ysmrr v0.2.1 github.com/fatih/color v1.16.0 @@ -19,22 +20,24 @@ require ( golang.org/x/tools v0.19.0 gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.2.1 - github.com/becheran/wildmatch-go v1.0.0 ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/alessio/shellescape v1.4.1 // indirect github.com/cli/browser v1.0.0 // indirect github.com/cli/safeexec v1.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-oauth2/oauth2 v3.9.2+incompatible // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -61,6 +64,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/zalando/go-keyring v0.2.5 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.16.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/go.sum b/go.sum index 7a44fbc6..8fc98695 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= @@ -75,6 +77,8 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -107,6 +111,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-oauth2/oauth2 v3.9.2+incompatible h1:A8gSjq4110EgZDVk4ZtcpusynU2Fto9eM6sXvxL+EOs= github.com/go-oauth2/oauth2 v3.9.2+incompatible/go.mod h1:GGcZ+i513KxN4yS7zBYfmwo3P+cyGvCS675uCNmWv/g= +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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -280,6 +286,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= diff --git a/internal/cmd/login/login.go b/internal/cmd/login/login.go index c2b26b48..cd9d4426 100644 --- a/internal/cmd/login/login.go +++ b/internal/cmd/login/login.go @@ -24,7 +24,7 @@ func NewLoginCmd(authenticator login.IAuthenticator) *cobra.Command { func RunE(a login.IAuthenticator) func(_ *cobra.Command, args []string) error { return func(cmd *cobra.Command, _ []string) error { - token, err := a.Authenticate() + token, err := login.AuthToken() if err != nil { return err } diff --git a/internal/login/auth.go b/internal/login/auth.go new file mode 100644 index 00000000..52312d64 --- /dev/null +++ b/internal/login/auth.go @@ -0,0 +1,141 @@ +package login + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + // "github.com/debricked/cli/internal/client" + "log" + "math/rand" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/oauth2" +) + +type IAuthenticator interface { + Authenticate() (*oauth2.Token, error) +} + +type Authenticator struct { + ClientID string + Scopes []string +} + +const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +func generateRandomString(length int) string { + seed := rand.NewSource(time.Now().UnixNano()) + r := rand.New(seed) + + b := make([]byte, length) + for i := range b { + b[i] = charset[r.Intn(len(charset))] + } + return string(b) +} + +func createCodeChallenge(codeVerifier string) string { + // Create a SHA-256 hash of the code verifier + hash := sha256.Sum256([]byte(codeVerifier)) + + // Encode the hash to base64 + encoded := base64.StdEncoding.EncodeToString(hash[:]) + + // Make it URL safe + encoded = strings.TrimRight(encoded, "=") + encoded = strings.ReplaceAll(encoded, "+", "-") + encoded = strings.ReplaceAll(encoded, "/", "_") + + return encoded +} + +func (a Authenticator) Authenticate() (*oauth2.Token, error) { + // Set up OAuth2 configuration + config := &oauth2.Config{ + ClientID: a.ClientID, + ClientSecret: "", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://debricked.com/app/oauth/authorize", + TokenURL: "https://debricked.com/app/oauth/token", + }, + RedirectURL: "http://localhost:9096/callback", + Scopes: a.Scopes, + } + + // Create a random state + state := generateRandomString(8) + codeVerifier := generateRandomString(64) + + // Generate the authorization URL + authURL := config.AuthCodeURL( + state, + oauth2.SetAuthURLParam("code_challenge", createCodeChallenge(codeVerifier)), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + // Start a temporary HTTP server to handle the callback + code := make(chan string) + defer close(code) + server := &http.Server{Addr: ":9096"} + // Start the server in a goroutine + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } + }() + + // Ensure the server is shut down when we're done + defer server.Shutdown(context.Background()) + + // Open the browser for the user to log in + err := openBrowser(authURL) + if err != nil { + log.Fatal("Could not open browser:", err) + } + + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + + code <- r.URL.Query().Get("code") + fmt.Fprintf(w, "Authentication successful! You can close this window now.") + }) + // Wait for the authorization code + authCode := <-code + + // Exchange the authorization code for a token + token, err := config.Exchange( + context.Background(), + authCode, + oauth2.SetAuthURLParam("client_id", a.ClientID), + oauth2.SetAuthURLParam("code_verifier", codeVerifier), + ) + if err != nil { + return nil, err + } + return token, nil +} + +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + return exec.Command(cmd, args...).Start() +} diff --git a/internal/login/login.go b/internal/login/login.go index 4c77bab3..0d75fa1d 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -1,136 +1,10 @@ package login -import ( - "context" - "crypto/sha256" - "encoding/base64" - "fmt" - "log" - "math/rand" - "net/http" - "os/exec" - "runtime" - "strings" - "time" - - "golang.org/x/oauth2" -) - -type IAuthenticator interface { - Authenticate() (string, error) -} - -type Authenticator struct { - ClientID string - Scopes []string -} - -const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - -func generateRandomString(length int) string { - seed := rand.NewSource(time.Now().UnixNano()) - r := rand.New(seed) - - b := make([]byte, length) - for i := range b { - b[i] = charset[r.Intn(len(charset))] - } - return string(b) -} - -func createCodeChallenge(codeVerifier string) string { - // Create a SHA-256 hash of the code verifier - hash := sha256.Sum256([]byte(codeVerifier)) - - // Encode the hash to base64 - encoded := base64.StdEncoding.EncodeToString(hash[:]) - - // Make it URL safe - encoded = strings.TrimRight(encoded, "=") - encoded = strings.ReplaceAll(encoded, "+", "-") - encoded = strings.ReplaceAll(encoded, "/", "_") - - return encoded -} - -func (a Authenticator) Authenticate() (string, error) { - // Set up OAuth2 configuration - config := &oauth2.Config{ - ClientID: a.ClientID, - ClientSecret: "", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://debricked.com/app/oauth/authorize", - TokenURL: "https://debricked.com/app/oauth/token", - }, - RedirectURL: "http://localhost:9096/callback", - Scopes: a.Scopes, - } - - // Create a random state - state := generateRandomString(8) - codeVerifier := generateRandomString(64) - - // Generate the authorization URL - authURL := config.AuthCodeURL( - state, - oauth2.SetAuthURLParam("code_challenge", createCodeChallenge(codeVerifier)), - oauth2.SetAuthURLParam("code_challenge_method", "S256"), - ) - - // Start a temporary HTTP server to handle the callback - code := make(chan string) - defer close(code) - server := &http.Server{Addr: ":9096"} - // Start the server in a goroutine - go func() { - if err := server.ListenAndServe(); err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) - } - }() - - // Ensure the server is shut down when we're done - defer server.Shutdown(context.Background()) - - // Open the browser for the user to log in - err := openBrowser(authURL) - if err != nil { - log.Fatal("Could not open browser:", err) - } - - http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - - code <- r.URL.Query().Get("code") - fmt.Fprintf(w, "Authentication successful! You can close this window now.") - }) - - // Wait for the authorization code - authCode := <-code - - // Exchange the authorization code for a token - token, err := config.Exchange(context.Background(), authCode) +func AuthToken() (string, error) { + tokenSource := GetDebrickedTokenSource() + token, err := tokenSource.Token() if err != nil { return "", err } return token.AccessToken, nil } - -func openBrowser(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - args = append(args, url) - return exec.Command(cmd, args...).Start() -} diff --git a/internal/login/token.go b/internal/login/token.go new file mode 100644 index 00000000..494fde82 --- /dev/null +++ b/internal/login/token.go @@ -0,0 +1,67 @@ +package login + +import ( + "github.com/zalando/go-keyring" + + "golang.org/x/oauth2" +) + +type SecretClient interface { + Set(string, string) error + Get(string) (string, error) +} + +type DebrickedSecretClient struct { + User string +} + +type DebrickedTokenSource struct { + SecretClient SecretClient +} + +func (dsc DebrickedSecretClient) Set(service, secret string) error { + return keyring.Set(service, dsc.User, secret) +} + +func (dsc DebrickedSecretClient) Get(service string) (string, error) { + return keyring.Get(service, dsc.User) +} + +func GetDebrickedTokenSource() oauth2.TokenSource { + return DebrickedTokenSource{ + SecretClient: DebrickedSecretClient{ + User: "DebrickedCLI", + }, + } +} + +func (dts DebrickedTokenSource) Token() (*oauth2.Token, error) { + refreshToken, err := dts.SecretClient.Get("DebrickedRefreshToken") + if err != nil { + if err == keyring.ErrNotFound { + // refreshToken is not yet set, initialize authorization + authenticator := Authenticator{ + ClientID: "01919462-7d6e-78e8-aa24-ba779213c90f", + Scopes: []string{"select", "profile", "basicRepo"}, + } + token, err := authenticator.Authenticate() + if err != nil { + return nil, err + } + dts.SecretClient.Set("DebrickedRefreshToken", token.RefreshToken) + dts.SecretClient.Set("DebrickedAccessToken", token.AccessToken) + } else { + return nil, err + } + } + accessToken, err := dts.SecretClient.Get("DebrickedAccessToken") + if err != nil { + accessToken = "" + } + // TODO: Parse expiry date + return &oauth2.Token{ + RefreshToken: refreshToken, + TokenType: "jwt", + AccessToken: accessToken, + }, nil +}