@@ -26,6 +26,20 @@ const (
2626 // maximum to the implementation; this prevents a misbehaving auth server
2727 // from growing the interval unboundedly.
2828 maxPollInterval = 60
29+ // defaultExpiresIn is the device-code lifetime in seconds used when the
30+ // device-code response omits expires_in.
31+ defaultExpiresIn = 900
32+ // maxExpiresIn caps the device-code lifetime a response may claim. It sits
33+ // well past GitHub's documented 15 minutes; the cap stops a misbehaving or
34+ // hostile response from pushing the deadline (and the polling loop) far
35+ // into the future.
36+ maxExpiresIn = 3600
37+ // incorrectDeviceCodeGraceRetries is the total number of extra polls a
38+ // single login may spend on incorrect_device_code before giving up — a
39+ // budget for the whole login, not per occurrence. cli/cli#9302 reports the
40+ // same error for a code that had just been issued; a genuinely invalid code
41+ // still fails, only a few seconds later.
42+ incorrectDeviceCodeGraceRetries = 2
2943)
3044
3145// DeviceCodeResponse represents the response from GitHub's device code endpoint
@@ -43,6 +57,24 @@ type AccessTokenResponse struct {
4357 TokenType string `json:"token_type"`
4458 Scope string `json:"scope"`
4559 Error string `json:"error,omitempty"`
60+ // ErrorDescription and ErrorURI accompany Error in GitHub's responses;
61+ // surfacing them turns opaque failures like "incorrect_device_code" into
62+ // diagnosable reports.
63+ ErrorDescription string `json:"error_description,omitempty"`
64+ ErrorURI string `json:"error_uri,omitempty"`
65+ }
66+
67+ // errorDetail renders Error together with any error_description and error_uri
68+ // GitHub returned, so failures reach the user diagnosable rather than opaque.
69+ func (r AccessTokenResponse ) errorDetail () string {
70+ detail := r .Error
71+ if r .ErrorDescription != "" {
72+ detail += ": " + r .ErrorDescription
73+ }
74+ if r .ErrorURI != "" {
75+ detail += " (" + r .ErrorURI + ")"
76+ }
77+ return detail
4678}
4779
4880// RegistryTokenResponse represents the response from registry's token exchange endpoint
@@ -61,9 +93,17 @@ type GitHubATProvider struct {
6193 // accessTokenURL is the GitHub access-token polling endpoint. It is a field
6294 // (rather than the package constant) so tests can point it at a mock server.
6395 accessTokenURL string
96+ // deviceCodeURL is the GitHub device-code endpoint, a field for the same
97+ // reason as accessTokenURL.
98+ deviceCodeURL string
6499 // pollInterval is the initial polling interval in seconds. Defaults to
65- // defaultPollInterval; overridable in tests to avoid real delays.
100+ // defaultPollInterval; overridable in tests to avoid real delays. Updated
101+ // from the device-code response's interval when GitHub returns one.
66102 pollInterval int
103+ // expiresIn is the device-code lifetime in seconds. Defaults to
104+ // defaultExpiresIn; updated from the device-code response's expires_in
105+ // when GitHub returns one.
106+ expiresIn int
67107 // sleep abstracts time.Sleep so tests can run without real delays and
68108 // assert the back-off sequence. Defaults to time.Sleep.
69109 sleep func (time.Duration )
@@ -86,7 +126,9 @@ func NewGitHubATProvider(registryURL, token string) Provider {
86126 registryURL : registryURL ,
87127 providedToken : token ,
88128 accessTokenURL : GitHubAccessTokenURL ,
129+ deviceCodeURL : GitHubDeviceCodeURL ,
89130 pollInterval : defaultPollInterval ,
131+ expiresIn : defaultExpiresIn ,
90132 sleep : time .Sleep ,
91133 }
92134}
@@ -173,7 +215,7 @@ func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, strin
173215 return "" , "" , "" , err
174216 }
175217
176- req , err := http .NewRequestWithContext (ctx , http .MethodPost , GitHubDeviceCodeURL , bytes .NewBuffer (jsonData ))
218+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , g . deviceCodeURL , bytes .NewBuffer (jsonData ))
177219 if err != nil {
178220 return "" , "" , "" , err
179221 }
@@ -202,6 +244,21 @@ func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, strin
202244 return "" , "" , "" , err
203245 }
204246
247+ // Per RFC 8628 §3.2 interval and expires_in are bound to this device code,
248+ // so reset any pacing left over from a previous code before adopting them.
249+ // Clamp both: a hostile or buggy response must not grow the interval past
250+ // maxPollInterval (the invariant maxPollInterval exists for) nor push the
251+ // deadline unboundedly into the future, and clamping also keeps the
252+ // derived time.Duration well clear of int64 overflow.
253+ g .pollInterval = defaultPollInterval
254+ g .expiresIn = defaultExpiresIn
255+ if deviceCodeResp .Interval > 0 {
256+ g .pollInterval = min (deviceCodeResp .Interval , maxPollInterval )
257+ }
258+ if deviceCodeResp .ExpiresIn > 0 {
259+ g .expiresIn = min (deviceCodeResp .ExpiresIn , maxExpiresIn )
260+ }
261+
205262 return deviceCodeResp .DeviceCode , deviceCodeResp .UserCode , deviceCodeResp .VerificationURI , nil
206263}
207264
@@ -222,11 +279,26 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
222279 return "" , err
223280 }
224281
225- // Default polling interval and expiration time
282+ // Pacing comes from the device-code response (via requestDeviceCode),
283+ // falling back to GitHub's documented defaults.
226284 interval := g .pollInterval
227- expiresIn := 900 // 15 minutes
285+ expiresIn := g .expiresIn
286+ // Zero-value fallback: providers built directly rather than through
287+ // requestDeviceCode (the test seam) carry no lifetime. Keep this guard.
288+ if expiresIn <= 0 {
289+ expiresIn = defaultExpiresIn
290+ }
228291 deadline := time .Now ().Add (time .Duration (expiresIn ) * time .Second )
229292
293+ graceRetries := 0
294+ // serverErrorInterval grows the wait between consecutive 5xx retries and is
295+ // reset by any other response.
296+ serverErrorInterval := interval
297+ // lastErr holds the most recent retryable failure so a deadline reached
298+ // mid-retry reports that diagnostic instead of a bare "timed out". It is
299+ // cleared whenever polling recovers, so a genuine user timeout after a
300+ // recovered blip still reports plainly.
301+ var lastErr error
230302 for time .Now ().Before (deadline ) {
231303 req , err := http .NewRequestWithContext (ctx , http .MethodPost , g .accessTokenURL , bytes .NewBuffer (jsonData ))
232304 if err != nil {
@@ -247,6 +319,25 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
247319 return "" , err
248320 }
249321
322+ // A transient 5xx must not abandon a login the user may already have
323+ // approved in the browser. Each consecutive 5xx grows the wait by 5s up
324+ // to maxPollInterval, so a sustained outage costs a couple of dozen
325+ // requests rather than hundreds; the deadline bounds the number of
326+ // these retries.
327+ if resp .StatusCode >= http .StatusInternalServerError {
328+ lastErr = fmt .Errorf ("token endpoint returned %d" , resp .StatusCode )
329+ serverErrorInterval = min (serverErrorInterval + 5 , maxPollInterval )
330+ g .sleep (time .Duration (serverErrorInterval ) * time .Second )
331+ continue
332+ }
333+ serverErrorInterval = interval
334+
335+ // Anything else outside 200 is not a device-flow answer: surface the
336+ // status and body rather than failing later on a JSON parse error.
337+ if resp .StatusCode != http .StatusOK {
338+ return "" , fmt .Errorf ("token endpoint returned %d: %s" , resp .StatusCode , body )
339+ }
340+
250341 var tokenResp AccessTokenResponse
251342 err = json .Unmarshal (body , & tokenResp )
252343 if err != nil {
@@ -263,12 +354,26 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
263354 interval = maxPollInterval
264355 }
265356 }
357+ lastErr = nil
266358 g .sleep (time .Duration (interval ) * time .Second )
267359 continue
268360 }
269361
270362 if tokenResp .Error != "" {
271- return "" , fmt .Errorf ("token request failed: %s" , tokenResp .Error )
363+ failure := fmt .Errorf ("token request failed: %s" , tokenResp .errorDetail ())
364+
365+ // incorrect_device_code has been reported for codes that were in
366+ // fact valid (cli/cli#9302; cause never confirmed), so spend a
367+ // couple of grace polls before abandoning a login the user may
368+ // have approved.
369+ if tokenResp .Error == "incorrect_device_code" && graceRetries < incorrectDeviceCodeGraceRetries {
370+ graceRetries ++
371+ lastErr = failure
372+ g .sleep (time .Duration (interval ) * time .Second )
373+ continue
374+ }
375+
376+ return "" , failure
272377 }
273378
274379 if tokenResp .AccessToken != "" {
@@ -279,6 +384,9 @@ func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string)
279384 return "" , fmt .Errorf ("failed to obtain access token" )
280385 }
281386
387+ if lastErr != nil {
388+ return "" , fmt .Errorf ("device code authorization timed out; last response: %w" , lastErr )
389+ }
282390 return "" , fmt .Errorf ("device code authorization timed out" )
283391}
284392
0 commit comments