commit
398bd27d02
8 changed files with 308 additions and 5 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
bin/gh
|
||||
/gh-cli
|
||||
.envrc
|
||||
|
|
|
|||
111
auth/oauth.go
Normal file
111
auth/oauth.go
Normal file
|
|
@ -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, "<p>You have successfully authenticated. You may now close this page.</p>")
|
||||
}
|
||||
}))
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
81
context/config_setup.go
Normal file
81
context/config_setup.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
32
context/config_success.go
Normal file
32
context/config_success.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package context
|
||||
|
||||
const oauthSuccessPage = `
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Success: GitHub CLI</title>
|
||||
<style type="text/css">
|
||||
body {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
max-width: 461px;
|
||||
margin: 2em auto;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 22px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<h1>Authentication successful.</h1>
|
||||
<p>
|
||||
You have completed logging into GitHub CLI.<br>
|
||||
You may now <strong>close this tab and return to the terminal</strong>.
|
||||
</p>
|
||||
<img alt="" src="https://octodex.github.com/images/daftpunktocat-guy.gif" height="461">
|
||||
</body>
|
||||
`
|
||||
59
context/config_viewer.go
Normal file
59
context/config_viewer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue