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 = ` + + +Success: GitHub CLI + + + +

Authentication successful.

+

+ 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 {