@@ -10,11 +10,11 @@ import (
1010 "net"
1111 "net/http"
1212 "net/url"
13- "os "
13+ "strconv "
1414 "strings"
15+ "time"
1516
1617 "github.com/cli/cli/internal/ghinstance"
17- "github.com/cli/cli/pkg/browser"
1818)
1919
2020func randomString (length int ) (string , error ) {
@@ -32,13 +32,130 @@ type OAuthFlow struct {
3232 ClientID string
3333 ClientSecret string
3434 Scopes []string
35+ OpenInBrowser func (string , string ) error
3536 WriteSuccessHTML func (io.Writer )
3637 VerboseStream io.Writer
38+ HTTPClient * http.Client
39+ TimeNow func () time.Time
40+ TimeSleep func (time.Duration )
3741}
3842
3943// ObtainAccessToken guides the user through the browser OAuth flow on GitHub
4044// and returns the OAuth access token upon completion.
4145func (oa * OAuthFlow ) ObtainAccessToken () (accessToken string , err error ) {
46+ // first, check if OAuth Device Flow is supported
47+ initURL := fmt .Sprintf ("https://%s/login/device/code" , oa .Hostname )
48+ tokenURL := fmt .Sprintf ("https://%s/login/oauth/access_token" , oa .Hostname )
49+
50+ oa .logf ("POST %s\n " , initURL )
51+ resp , err := oa .HTTPClient .PostForm (initURL , url.Values {
52+ "client_id" : {oa .ClientID },
53+ "scope" : {strings .Join (oa .Scopes , " " )},
54+ })
55+ if err != nil {
56+ return
57+ }
58+ defer resp .Body .Close ()
59+
60+ if resp .StatusCode == 401 || resp .StatusCode == 403 || resp .StatusCode == 404 {
61+ // OAuth Device Flow is not available; continue with OAuth browser flow with a
62+ // local server endpoint as callback target
63+ return oa .localServerFlow ()
64+ } else if resp .StatusCode != 200 {
65+ return "" , fmt .Errorf ("error: HTTP %d (%s)" , resp .StatusCode , initURL )
66+ }
67+
68+ bb , err := ioutil .ReadAll (resp .Body )
69+ if err != nil {
70+ return
71+ }
72+ values , err := url .ParseQuery (string (bb ))
73+ if err != nil {
74+ return
75+ }
76+
77+ timeNow := oa .TimeNow
78+ if timeNow == nil {
79+ timeNow = time .Now
80+ }
81+ timeSleep := oa .TimeSleep
82+ if timeSleep == nil {
83+ timeSleep = time .Sleep
84+ }
85+
86+ intervalSeconds , err := strconv .Atoi (values .Get ("interval" ))
87+ if err != nil {
88+ return "" , fmt .Errorf ("could not parse interval=%q as integer: %w" , values .Get ("interval" ), err )
89+ }
90+ checkInterval := time .Duration (intervalSeconds ) * time .Second
91+
92+ expiresIn , err := strconv .Atoi (values .Get ("expires_in" ))
93+ if err != nil {
94+ return "" , fmt .Errorf ("could not parse expires_in=%q as integer: %w" , values .Get ("expires_in" ), err )
95+ }
96+ expiresAt := timeNow ().Add (time .Duration (expiresIn ) * time .Second )
97+
98+ err = oa .OpenInBrowser (values .Get ("verification_uri" ), values .Get ("user_code" ))
99+ if err != nil {
100+ return
101+ }
102+
103+ for {
104+ timeSleep (checkInterval )
105+ accessToken , err = oa .deviceFlowPing (tokenURL , values .Get ("device_code" ))
106+ if accessToken == "" && err == nil {
107+ if timeNow ().After (expiresAt ) {
108+ err = errors .New ("authentication timed out" )
109+ } else {
110+ continue
111+ }
112+ }
113+ break
114+ }
115+
116+ return
117+ }
118+
119+ func (oa * OAuthFlow ) deviceFlowPing (tokenURL , deviceCode string ) (accessToken string , err error ) {
120+ oa .logf ("POST %s\n " , tokenURL )
121+ resp , err := oa .HTTPClient .PostForm (tokenURL , url.Values {
122+ "client_id" : {oa .ClientID },
123+ "device_code" : {deviceCode },
124+ "grant_type" : {"urn:ietf:params:oauth:grant-type:device_code" },
125+ })
126+ if err != nil {
127+ return "" , err
128+ }
129+ defer resp .Body .Close ()
130+ if resp .StatusCode != 200 {
131+ return "" , fmt .Errorf ("error: HTTP %d (%s)" , resp .StatusCode , tokenURL )
132+ }
133+
134+ bb , err := ioutil .ReadAll (resp .Body )
135+ if err != nil {
136+ return "" , err
137+ }
138+ values , err := url .ParseQuery (string (bb ))
139+ if err != nil {
140+ return "" , err
141+ }
142+
143+ if accessToken := values .Get ("access_token" ); accessToken != "" {
144+ return accessToken , nil
145+ }
146+
147+ errorType := values .Get ("error" )
148+ if errorType == "authorization_pending" {
149+ return "" , nil
150+ }
151+
152+ if errorDescription := values .Get ("error_description" ); errorDescription != "" {
153+ return "" , errors .New (errorDescription )
154+ }
155+ return "" , errors .New ("OAuth device flow error" )
156+ }
157+
158+ func (oa * OAuthFlow ) localServerFlow () (accessToken string , err error ) {
42159 state , _ := randomString (20 )
43160
44161 code := ""
@@ -70,15 +187,9 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
70187
71188 startURL := fmt .Sprintf ("https://%s/login/oauth/authorize?%s" , oa .Hostname , q .Encode ())
72189 oa .logf ("open %s\n " , startURL )
73- if err := openInBrowser (startURL ); err != nil {
74- fmt .Fprintf (os .Stderr , "error opening web browser: %s\n " , err )
75- fmt .Fprintf (os .Stderr , "" )
76- fmt .Fprintf (os .Stderr , "Please open the following URL manually:\n %s\n " , startURL )
77- fmt .Fprintf (os .Stderr , "" )
78- // TODO: Temporary workaround for https://github.com/cli/cli/issues/297
79- fmt .Fprintf (os .Stderr , "If you are on a server or other headless system, use this workaround instead:\n " )
80- fmt .Fprintf (os .Stderr , " 1. Complete authentication on a GUI system;\n " )
81- fmt .Fprintf (os .Stderr , " 2. Copy the contents of `~/.config/gh/hosts.yml` to this system.\n " )
190+ err = oa .OpenInBrowser (startURL , "" )
191+ if err != nil {
192+ return
82193 }
83194
84195 _ = http .Serve (listener , http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
@@ -105,7 +216,7 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
105216
106217 tokenURL := fmt .Sprintf ("https://%s/login/oauth/access_token" , oa .Hostname )
107218 oa .logf ("POST %s\n " , tokenURL )
108- resp , err := http .PostForm (tokenURL ,
219+ resp , err := oa . HTTPClient .PostForm (tokenURL ,
109220 url.Values {
110221 "client_id" : {oa .ClientID },
111222 "client_secret" : {oa .ClientSecret },
@@ -143,11 +254,3 @@ func (oa *OAuthFlow) logf(format string, args ...interface{}) {
143254 }
144255 fmt .Fprintf (oa .VerboseStream , format , args ... )
145256}
146-
147- func openInBrowser (url string ) error {
148- cmd , err := browser .Command (url )
149- if err != nil {
150- return err
151- }
152- return cmd .Run ()
153- }
0 commit comments