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 }