From d90552454dfdbeba83a008f041a97ba122bab40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 8 Oct 2019 18:18:59 +0200 Subject: [PATCH 1/7] Preliminary OAuth flow --- .gitignore | 1 + auth/oauth.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 auth/oauth.go 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..b6a0cd2ce --- /dev/null +++ b/auth/oauth.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/exec" + "time" +) + +func main() { + port := 3999 // TODO: find available port number + state := "TODO" // replace with random unguessable value + + clientID := os.Getenv("GH_OAUTH_CLIENT_ID") + clientSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET") + + q := url.Values{} + q.Set("client_id", clientID) + q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d", port)) + q.Set("scope", "repo") + q.Set("state", state) + + cmd := exec.Command("open", fmt.Sprintf("https://github.com/login/oauth/authorize?%s", q.Encode())) + err := cmd.Run() + if err != nil { + panic(err) + } + + code := "" + srv := &http.Server{Addr: fmt.Sprintf(":%d", port)} + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + rq := r.URL.Query() + code = rq.Get("code") + // TODO: rq.Get("state") + w.Header().Add("content-type", "text/html") + fmt.Fprintf(w, "

You have authenticated GitHub CLI. You may now close this page.

") + time.AfterFunc(10*time.Millisecond, func() { + srv.Shutdown(context.TODO()) + }) + }) + srv.ListenAndServe() + + resp, err := http.PostForm("https://github.com/login/oauth/access_token", + url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "state": {state}, + }) + if err != nil { + panic(err) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + panic(err) + } + tokenValues, err := url.ParseQuery(string(body)) + if err != nil { + panic(err) + } + fmt.Printf("OAuth access token: %s\n", tokenValues.Get("access_token")) +} From db0084f62329991529d2d0abfb2fa6652057f30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Oct 2019 15:35:11 +0200 Subject: [PATCH 2/7] One weird trick to prevent macOS firewall popup Discovered by a stay-at-home developer! --- auth/oauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/oauth.go b/auth/oauth.go index b6a0cd2ce..714aed099 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -31,7 +31,7 @@ func main() { } code := "" - srv := &http.Server{Addr: fmt.Sprintf(":%d", port)} + srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", port)} http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { rq := r.URL.Query() code = rq.Get("code") From 216ffb89e2a94a0e5f3dd6c764eccae557a7f31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Oct 2019 16:00:34 +0200 Subject: [PATCH 3/7] Use random available port number --- auth/oauth.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/auth/oauth.go b/auth/oauth.go index 714aed099..9a0f33f8a 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -1,23 +1,28 @@ package main import ( - "context" "fmt" "io/ioutil" + "net" "net/http" "net/url" "os" "os/exec" - "time" ) func main() { - port := 3999 // TODO: find available port number state := "TODO" // replace with random unguessable value clientID := os.Getenv("GH_OAUTH_CLIENT_ID") clientSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET") + code := "" + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + panic(err) + } + port := listener.Addr().(*net.TCPAddr).Port + q := url.Values{} q.Set("client_id", clientID) q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d", port)) @@ -25,24 +30,19 @@ func main() { q.Set("state", state) cmd := exec.Command("open", fmt.Sprintf("https://github.com/login/oauth/authorize?%s", q.Encode())) - err := cmd.Run() + err = cmd.Run() if err != nil { panic(err) } - code := "" - srv := &http.Server{Addr: fmt.Sprintf("localhost:%d", port)} - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rq := r.URL.Query() code = rq.Get("code") // TODO: rq.Get("state") w.Header().Add("content-type", "text/html") fmt.Fprintf(w, "

You have authenticated GitHub CLI. You may now close this page.

") - time.AfterFunc(10*time.Millisecond, func() { - srv.Shutdown(context.TODO()) - }) - }) - srv.ListenAndServe() + defer listener.Close() + })) resp, err := http.PostForm("https://github.com/login/oauth/access_token", url.Values{ From 7bf306f022d351071a9389718540c54d5b79b667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Oct 2019 16:34:40 +0200 Subject: [PATCH 4/7] Generate and verify random "state" value This is for extra security during OAuth flow. --- auth/oauth.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/auth/oauth.go b/auth/oauth.go index 9a0f33f8a..63a9c1d01 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -1,6 +1,7 @@ package main import ( + "crypto/rand" "fmt" "io/ioutil" "net" @@ -10,8 +11,17 @@ import ( "os/exec" ) +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 +} + func main() { - state := "TODO" // replace with random unguessable value + state, _ := randomString(20) clientID := os.Getenv("GH_OAUTH_CLIENT_ID") clientSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET") @@ -36,12 +46,15 @@ func main() { } 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") - // TODO: rq.Get("state") w.Header().Add("content-type", "text/html") fmt.Fprintf(w, "

You have authenticated GitHub CLI. You may now close this page.

") - defer listener.Close() })) resp, err := http.PostForm("https://github.com/login/oauth/access_token", From de85294c790a54b8acb6606536edf1f1094027ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 9 Oct 2019 16:48:51 +0200 Subject: [PATCH 5/7] Extract OAuth logic into a struct --- auth/oauth.go | 41 ++++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/auth/oauth.go b/auth/oauth.go index 63a9c1d01..a0978aea2 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -11,6 +11,18 @@ import ( "os/exec" ) +func main() { + oa := OAuthFlow{ + ClientID: os.Getenv("GH_OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("GH_OAUTH_CLIENT_SECRET"), + } + token, err := oa.ObtainAccessToken() + if err != nil { + panic(err) + } + fmt.Printf("OAuth access token: %s\n", token) +} + func randomString(length int) (string, error) { b := make([]byte, length/2) _, err := rand.Read(b) @@ -20,21 +32,23 @@ func randomString(length int) (string, error) { return fmt.Sprintf("%x", b), nil } -func main() { - state, _ := randomString(20) +type OAuthFlow struct { + ClientID string + ClientSecret string +} - clientID := os.Getenv("GH_OAUTH_CLIENT_ID") - clientSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET") +func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { + state, _ := randomString(20) code := "" listener, err := net.Listen("tcp", "localhost:0") if err != nil { - panic(err) + return } port := listener.Addr().(*net.TCPAddr).Port q := url.Values{} - q.Set("client_id", clientID) + q.Set("client_id", oa.ClientID) q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d", port)) q.Set("scope", "repo") q.Set("state", state) @@ -42,7 +56,7 @@ func main() { cmd := exec.Command("open", fmt.Sprintf("https://github.com/login/oauth/authorize?%s", q.Encode())) err = cmd.Run() if err != nil { - panic(err) + return } http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -59,23 +73,24 @@ func main() { resp, err := http.PostForm("https://github.com/login/oauth/access_token", url.Values{ - "client_id": {clientID}, - "client_secret": {clientSecret}, + "client_id": {oa.ClientID}, + "client_secret": {oa.ClientSecret}, "code": {code}, "state": {state}, }) if err != nil { - panic(err) + return } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - panic(err) + return } tokenValues, err := url.ParseQuery(string(body)) if err != nil { - panic(err) + return } - fmt.Printf("OAuth access token: %s\n", tokenValues.Get("access_token")) + accessToken = tokenValues.Get("access_token") + return } From 2aa77fb8ea8b49db4e80a0df3d20ec33be6fa911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 18 Oct 2019 18:47:42 +0200 Subject: [PATCH 6/7] Add Context.SetAuthToken --- context/blank_context.go | 4 ++++ context/context.go | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) 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/context.go b/context/context.go index 4e72e4e2f..86c8767fa 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,10 +37,11 @@ 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 { @@ -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 { From 5aca57596429a0a43b44b904c7925ad0b99c3ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 18 Oct 2019 19:08:11 +0200 Subject: [PATCH 7/7] Wire up OAuth authentication flow to initialize config file The config file is now `~/.config/gh`. --- auth/oauth.go | 57 +++++++++++++++++---------- context/config_file.go | 4 ++ context/config_setup.go | 81 +++++++++++++++++++++++++++++++++++++++ context/config_success.go | 32 ++++++++++++++++ context/config_viewer.go | 59 ++++++++++++++++++++++++++++ context/context.go | 2 +- 6 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 context/config_setup.go create mode 100644 context/config_success.go create mode 100644 context/config_viewer.go diff --git a/auth/oauth.go b/auth/oauth.go index a0978aea2..dba975f41 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -1,28 +1,18 @@ -package main +package auth import ( "crypto/rand" "fmt" + "io" "io/ioutil" "net" "net/http" "net/url" "os" "os/exec" + "runtime" ) -func main() { - oa := OAuthFlow{ - ClientID: os.Getenv("GH_OAUTH_CLIENT_ID"), - ClientSecret: os.Getenv("GH_OAUTH_CLIENT_SECRET"), - } - token, err := oa.ObtainAccessToken() - if err != nil { - panic(err) - } - fmt.Printf("OAuth access token: %s\n", token) -} - func randomString(length int) (string, error) { b := make([]byte, length/2) _, err := rand.Read(b) @@ -32,11 +22,16 @@ func randomString(length int) (string, error) { return fmt.Sprintf("%x", b), nil } +// OAuthFlow represents the setup for authenticating with GitHub type OAuthFlow struct { - ClientID string - ClientSecret string + 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) @@ -53,10 +48,10 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { q.Set("scope", "repo") q.Set("state", state) - cmd := exec.Command("open", fmt.Sprintf("https://github.com/login/oauth/authorize?%s", q.Encode())) - err = cmd.Run() - if err != nil { - return + 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) { @@ -68,10 +63,14 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { } code = rq.Get("code") w.Header().Add("content-type", "text/html") - fmt.Fprintf(w, "

You have authenticated GitHub CLI. You may now close this page.

") + 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("https://github.com/login/oauth/access_token", + resp, err := http.PostForm(fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname), url.Values{ "client_id": {oa.ClientID}, "client_secret": {oa.ClientSecret}, @@ -94,3 +93,19 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { 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/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 86c8767fa..3e6dd009e 100644 --- a/context/context.go +++ b/context/context.go @@ -45,7 +45,7 @@ type fsContext struct { } func (c *fsContext) configFile() string { - dir, _ := homedir.Expand("~/.config/hub") + dir, _ := homedir.Expand("~/.config/gh") return dir }