Visitar URL original
fix(publisher): tolerate transient device-flow failures during login … · modelcontextprotocol/registry@2a97336 · GitHub
Skip to content

Commit 2a97336

Browse files
committed
fix(publisher): tolerate transient device-flow failures during login polling
Device-flow login gave up on the first non-pending answer from GitHub's token endpoint, so a single transient incorrect_device_code or 5xx at the moment the user authorised discarded a login they had already approved (#1543). Spend a small number of grace polls on incorrect_device_code and retry transient 5xx responses, both bounded by the existing device-code deadline; a genuinely invalid code still fails, only seconds later. Adopt the interval and expires_in the device-code response supplies instead of hard-coded defaults, clamped to safe bounds and reset per code, and surface error_description and error_uri so the remaining failures are diagnosable. A timeout now reports the last retryable response it saw.
1 parent b0e40ee commit 2a97336

2 files changed

Lines changed: 590 additions & 5 deletions

File tree

‎cmd/publisher/auth/github-at.go‎

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)