github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/internal/authflow/flow.go (about)

     1  package authflow
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  	"net/url"
     9  	"os"
    10  	"regexp"
    11  	"strings"
    12  
    13  	"github.com/ungtb10d/cli/v2/api"
    14  	"github.com/ungtb10d/cli/v2/internal/browser"
    15  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    16  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    17  	"github.com/ungtb10d/cli/v2/utils"
    18  	"github.com/cli/oauth"
    19  	"github.com/henvic/httpretty"
    20  )
    21  
    22  var (
    23  	// The "GitHub CLI" OAuth app
    24  	oauthClientID = "178c6fc778ccc68e1d6a"
    25  	// This value is safe to be embedded in version control
    26  	oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
    27  
    28  	jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
    29  )
    30  
    31  type iconfig interface {
    32  	Get(string, string) (string, error)
    33  	Set(string, string, string)
    34  	Write() error
    35  }
    36  
    37  func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) {
    38  	// TODO this probably shouldn't live in this package. It should probably be in a new package that
    39  	// depends on both iostreams and config.
    40  
    41  	// FIXME: this duplicates `factory.browserLauncher()`
    42  	browserLauncher := os.Getenv("GH_BROWSER")
    43  	if browserLauncher == "" {
    44  		browserLauncher, _ = cfg.Get("", "browser")
    45  	}
    46  	if browserLauncher == "" {
    47  		browserLauncher = os.Getenv("BROWSER")
    48  	}
    49  
    50  	token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive, browserLauncher)
    51  	if err != nil {
    52  		return "", err
    53  	}
    54  
    55  	cfg.Set(hostname, "user", userLogin)
    56  	cfg.Set(hostname, "oauth_token", token)
    57  
    58  	return token, cfg.Write()
    59  }
    60  
    61  func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, browserLauncher string) (string, string, error) {
    62  	w := IO.ErrOut
    63  	cs := IO.ColorScheme()
    64  
    65  	httpClient := &http.Client{}
    66  	debugEnabled, debugValue := utils.IsDebugEnabled()
    67  	if debugEnabled {
    68  		logTraffic := strings.Contains(debugValue, "api")
    69  		httpClient.Transport = verboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
    70  	}
    71  
    72  	minimumScopes := []string{"repo", "read:org", "gist"}
    73  	scopes := append(minimumScopes, additionalScopes...)
    74  
    75  	callbackURI := "http://127.0.0.1/callback"
    76  	if ghinstance.IsEnterprise(oauthHost) {
    77  		// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
    78  		// see https://github.com/ungtb10d/cli/pull/222, https://github.com/ungtb10d/cli/pull/650
    79  		callbackURI = "http://localhost/"
    80  	}
    81  
    82  	flow := &oauth.Flow{
    83  		Host:         oauth.GitHubHost(ghinstance.HostPrefix(oauthHost)),
    84  		ClientID:     oauthClientID,
    85  		ClientSecret: oauthClientSecret,
    86  		CallbackURI:  callbackURI,
    87  		Scopes:       scopes,
    88  		DisplayCode: func(code, verificationURL string) error {
    89  			fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
    90  			return nil
    91  		},
    92  		BrowseURL: func(authURL string) error {
    93  			if u, err := url.Parse(authURL); err == nil {
    94  				if u.Scheme != "http" && u.Scheme != "https" {
    95  					return fmt.Errorf("invalid URL: %s", authURL)
    96  				}
    97  			} else {
    98  				return err
    99  			}
   100  
   101  			if !isInteractive {
   102  				fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), authURL)
   103  				return nil
   104  			}
   105  
   106  			fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
   107  			_ = waitForEnter(IO.In)
   108  
   109  			b := browser.New(browserLauncher, IO.Out, IO.ErrOut)
   110  			if err := b.Browse(authURL); err != nil {
   111  				fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), authURL)
   112  				fmt.Fprintf(w, "  %s\n", err)
   113  				fmt.Fprint(w, "  Please try entering the URL in your browser manually\n")
   114  			}
   115  			return nil
   116  		},
   117  		WriteSuccessHTML: func(w io.Writer) {
   118  			fmt.Fprint(w, oauthSuccessPage)
   119  		},
   120  		HTTPClient: httpClient,
   121  		Stdin:      IO.In,
   122  		Stdout:     w,
   123  	}
   124  
   125  	fmt.Fprintln(w, notice)
   126  
   127  	token, err := flow.DetectFlow()
   128  	if err != nil {
   129  		return "", "", err
   130  	}
   131  
   132  	userLogin, err := getViewer(oauthHost, token.Token, IO.ErrOut)
   133  	if err != nil {
   134  		return "", "", err
   135  	}
   136  
   137  	return token.Token, userLogin, nil
   138  }
   139  
   140  type cfg struct {
   141  	authToken string
   142  }
   143  
   144  func (c cfg) AuthToken(hostname string) (string, string) {
   145  	return c.authToken, "oauth_token"
   146  }
   147  
   148  func getViewer(hostname, token string, logWriter io.Writer) (string, error) {
   149  	opts := api.HTTPClientOptions{
   150  		Config: cfg{authToken: token},
   151  		Log:    logWriter,
   152  	}
   153  	client, err := api.NewHTTPClient(opts)
   154  	if err != nil {
   155  		return "", err
   156  	}
   157  	return api.CurrentLoginName(api.NewClientFromHTTP(client), hostname)
   158  }
   159  
   160  func waitForEnter(r io.Reader) error {
   161  	scanner := bufio.NewScanner(r)
   162  	scanner.Scan()
   163  	return scanner.Err()
   164  }
   165  
   166  func verboseLog(out io.Writer, logTraffic bool, colorize bool) func(http.RoundTripper) http.RoundTripper {
   167  	logger := &httpretty.Logger{
   168  		Time:            true,
   169  		TLS:             false,
   170  		Colors:          colorize,
   171  		RequestHeader:   logTraffic,
   172  		RequestBody:     logTraffic,
   173  		ResponseHeader:  logTraffic,
   174  		ResponseBody:    logTraffic,
   175  		Formatters:      []httpretty.Formatter{&httpretty.JSONFormatter{}},
   176  		MaxResponseBody: 10000,
   177  	}
   178  	logger.SetOutput(out)
   179  	logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
   180  		return !inspectableMIMEType(h.Get("Content-Type")), nil
   181  	})
   182  	return logger.RoundTripper
   183  }
   184  
   185  func inspectableMIMEType(t string) bool {
   186  	return strings.HasPrefix(t, "text/") ||
   187  		strings.HasPrefix(t, "application/x-www-form-urlencoded") ||
   188  		jsonTypeRE.MatchString(t)
   189  }