diff --git a/.gitignore b/.gitignore index 450ea9096..2b35ac2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/gh /gh-cli +.envrc diff --git a/auth/oauth.go b/auth/oauth.go new file mode 100644 index 000000000..dba975f41 --- /dev/null +++ b/auth/oauth.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/rand" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" +) + +func randomString(length int) (string, error) { + b := make([]byte, length/2) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return fmt.Sprintf("%x", b), nil +} + +// OAuthFlow represents the setup for authenticating with GitHub +type OAuthFlow struct { + Hostname string + ClientID string + ClientSecret string + WriteSuccessHTML func(io.Writer) +} + +// ObtainAccessToken guides the user through the browser OAuth flow on GitHub +// and returns the OAuth access token upon completion. +func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { + state, _ := randomString(20) + + code := "" + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + return + } + port := listener.Addr().(*net.TCPAddr).Port + + q := url.Values{} + q.Set("client_id", oa.ClientID) + q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d", port)) + q.Set("scope", "repo") + q.Set("state", state) + + startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode()) + if err := openInBrowser(startURL); err != nil { + fmt.Fprintf(os.Stderr, "error opening web browser: %s\n", err) + fmt.Fprintf(os.Stderr, "Please open the following URL manually:\n%s\n", startURL) + } + + http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer listener.Close() + rq := r.URL.Query() + if state != rq.Get("state") { + fmt.Fprintf(w, "Error: state mismatch") + return + } + code = rq.Get("code") + w.Header().Add("content-type", "text/html") + if oa.WriteSuccessHTML != nil { + oa.WriteSuccessHTML(w) + } else { + fmt.Fprintf(w, "
You have successfully authenticated. You may now close this page.
") + } + })) + + resp, err := http.PostForm(fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname), + url.Values{ + "client_id": {oa.ClientID}, + "client_secret": {oa.ClientSecret}, + "code": {code}, + "state": {state}, + }) + if err != nil { + return + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + tokenValues, err := url.ParseQuery(string(body)) + if err != nil { + return + } + accessToken = tokenValues.Get("access_token") + return +} + +func openInBrowser(url string) error { + var args []string + switch runtime.GOOS { + case "darwin": + args = []string{"open"} + case "windows": + args = []string{"cmd", "/c", "start"} + default: + args = []string{"xdg-open"} + } + + args = append(args, url) + cmd := exec.Command(args[0], args[1:]...) + return cmd.Run() +} diff --git a/context/blank_context.go b/context/blank_context.go index 166026598..e7e48c878 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -26,6 +26,10 @@ func (c *blankContext) AuthToken() (string, error) { return c.authToken, nil } +func (c *blankContext) SetAuthToken(t string) { + c.authToken = t +} + func (c *blankContext) AuthLogin() (string, error) { return c.authLogin, nil } diff --git a/context/config_file.go b/context/config_file.go index f0a6f4b81..48f9ce482 100644 --- a/context/config_file.go +++ b/context/config_file.go @@ -1,6 +1,7 @@ package context import ( + "errors" "fmt" "io" "io/ioutil" @@ -17,6 +18,9 @@ type configEntry struct { func parseConfigFile(fn string) (*configEntry, error) { f, err := os.Open(fn) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return setupConfigFile(fn) + } return nil, err } defer f.Close() diff --git a/context/config_setup.go b/context/config_setup.go new file mode 100644 index 000000000..115d31475 --- /dev/null +++ b/context/config_setup.go @@ -0,0 +1,81 @@ +package context + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/github/gh-cli/auth" + "gopkg.in/yaml.v3" +) + +const ( + oauthHost = "github.com" + // The GitHub app that is meant for development + oauthClientID = "4d747ba5675d5d66553f" + // This value is safe to be embedded in version control + oauthClientSecret = "d4fee7b3f9c2ef4284a5ca7be0ee200cf15b6f8d" +) + +// TODO: have a conversation about whether this belongs in the "context" package +func setupConfigFile(filename string) (*configEntry, error) { + flow := &auth.OAuthFlow{ + Hostname: oauthHost, + ClientID: oauthClientID, + ClientSecret: oauthClientSecret, + WriteSuccessHTML: func(w io.Writer) { + fmt.Fprintln(w, oauthSuccessPage) + }, + } + + fmt.Fprintln(os.Stderr, "Notice: authentication required") + fmt.Fprintf(os.Stderr, "Press Enter to open %s in your web browser... ", flow.Hostname) + waitForEnter(os.Stdin) + token, err := flow.ObtainAccessToken() + if err != nil { + return nil, err + } + + u, err := getViewer(token) + if err != nil { + return nil, err + } + entry := configEntry{ + User: u.Login, + Token: token, + } + data := make(map[string][]configEntry) + data[flow.Hostname] = []configEntry{entry} + + err = os.MkdirAll(filepath.Dir(filename), 0771) + if err != nil { + return nil, err + } + + config, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return nil, err + } + defer config.Close() + + yamlData, err := yaml.Marshal(data) + n, err := config.Write(yamlData) + if err == nil && n < len(yamlData) { + err = io.ErrShortWrite + } + + if err == nil { + fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ") + waitForEnter(os.Stdin) + } + + return &entry, err +} + +func waitForEnter(r io.Reader) error { + scanner := bufio.NewScanner(r) + scanner.Scan() + return scanner.Err() +} diff --git a/context/config_success.go b/context/config_success.go new file mode 100644 index 000000000..3a6fe978b --- /dev/null +++ b/context/config_success.go @@ -0,0 +1,32 @@ +package context + +const oauthSuccessPage = ` + + +
+ You have completed logging into GitHub CLI.
+ You may now close this tab and return to the terminal.
+
+
+`
diff --git a/context/config_viewer.go b/context/config_viewer.go
new file mode 100644
index 000000000..0a03cdca3
--- /dev/null
+++ b/context/config_viewer.go
@@ -0,0 +1,59 @@
+package context
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/ioutil"
+ "net/http"
+)
+
+type viewer struct {
+ Login string
+}
+type responseData struct {
+ Data struct {
+ Viewer *viewer
+ }
+}
+
+// TODO: figure out how to enable using the "api" package here
+//
+// Right now "api" is coupled to "context", so we can't import "api" from here.
+func getViewer(token string) (user *viewer, err error) {
+ url := "https://api.github.com/graphql"
+ query := `{ viewer { login } }`
+
+ reqBody, err := json.Marshal(map[string]interface{}{"query": query})
+ if err != nil {
+ return
+ }
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
+ if err != nil {
+ return
+ }
+
+ req.Header.Set("Authorization", "token "+token)
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
+
+ client := http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return
+ }
+
+ data := responseData{}
+ err = json.Unmarshal(body, &data)
+ if err != nil {
+ return
+ }
+
+ user = data.Data.Viewer
+ return
+}
diff --git a/context/context.go b/context/context.go
index 4e72e4e2f..3e6dd009e 100644
--- a/context/context.go
+++ b/context/context.go
@@ -10,6 +10,7 @@ import (
// Context represents the interface for querying information about the current environment
type Context interface {
AuthToken() (string, error)
+ SetAuthToken(string)
AuthLogin() (string, error)
Branch() (string, error)
SetBranch(string)
@@ -36,14 +37,15 @@ func InitDefaultContext() Context {
// A Context implementation that queries the filesystem
type fsContext struct {
- config *configEntry
- remotes Remotes
- branch string
- baseRepo *GitHubRepository
+ config *configEntry
+ remotes Remotes
+ branch string
+ baseRepo *GitHubRepository
+ authToken string
}
func (c *fsContext) configFile() string {
- dir, _ := homedir.Expand("~/.config/hub")
+ dir, _ := homedir.Expand("~/.config/gh")
return dir
}
@@ -54,11 +56,16 @@ func (c *fsContext) getConfig() (*configEntry, error) {
return nil, err
}
c.config = entry
+ c.authToken = ""
}
return c.config, nil
}
func (c *fsContext) AuthToken() (string, error) {
+ if c.authToken != "" {
+ return c.authToken, nil
+ }
+
config, err := c.getConfig()
if err != nil {
return "", err
@@ -66,6 +73,10 @@ func (c *fsContext) AuthToken() (string, error) {
return config.Token, nil
}
+func (c *fsContext) SetAuthToken(t string) {
+ c.authToken = t
+}
+
func (c *fsContext) AuthLogin() (string, error) {
config, err := c.getConfig()
if err != nil {