Skip to content

fix(auth): retry transient device-flow failures, check the device-code answer, tighten search token scoping - #82

Merged
lamdor merged 7 commits into
mainfrom
simplify-device-flow
Nov 5, 2026
Merged

lamdor merged 7 commits into
mainfrom
simplify-device-flow

Conversation

@pankgeorg

Copy link
Copy Markdown
Member

This is a follow-up to #64. The device-flow loop that #64 introduced already follows RFC 8628 for interval, slow_down, expires_in and the terminal errors. This PR shares one token request between polling and refresh, and fixes what a post-merge review found around that loop and in the search token scoping. Each commit stands alone.

Simplification

  • One token-endpoint request (refactor). postTokenForm now does the POST, read and decode that pollDeviceToken and refreshToken each did on their own.
    • refreshToken takes the stored token and resolves the dex host from its issuer itself, so its two callers no longer call rememberAuthServerFromToken first.
    • The refresh request now has the same 30 s timeout as polling. Before, it had none.

Fixes

  • Polling survives transient failures. A timeout, a reset connection, or a 502/503 page from the ingress used to end the login, even after the user had approved it.
    • RFC 8628 §3.5 asks clients to back off and retry. Such polls now double the interval (capped at a minute) and retry until the device code expires. The expiry error wraps the last failure.
    • A non-JSON answer below 500 still fails at once, and the message includes its HTTP status.
    • Without expires_in, polling now stops after 15 minutes. Before, it had no bound.
  • The device-code answer is checked. An error answer used to decode into an empty response. jh then printed Go to and authorize this device and failed on the first poll with invalid_request.
    • requestDeviceCode now checks the status and requires a device_code.
    • The request has a timeout.
    • When verification_uri_complete is missing (it is optional in the RFC), the prompt shows verification_uri and the user code.
  • Login prompts go to stderr. jh git-credential get runs the device flow when the stored login is for another host. git reads the helper's stdout as credential lines, so the approval URL never reached the user, and git rejected the extra lines.
  • Search token scoping compares the actual destination host. loginMatchesServer used to normalize the destination too. So --server https://example.org matched a login stored for example.org.juliahub.com and was sent its token. Every caller already passes the real destination host, so now only the stored value is normalized.
  • No unfiltered GraphQL fallback. When the registry list can't be fetched, jh package search --registries X keeps the names for REST but has no registry IDs. If REST also failed, the GraphQL fallback used to run with no registry filter. It now reports the REST error instead.

Also

  • Two doc comments (runPackageSearch, resolveRegistries) had ended up above neighbouring declarations. They are back on their functions.
  • CLAUDE.md describes the new retry and prompt rules.

Testing

New tests:

  • TestPollDeviceTokenRetriesTransientFailures (502 → backoff → success)
  • TestPollDeviceTokenBacksOffUntilExpiry (unreachable endpoint; the backoff sequence and the cap)
  • TestPollDeviceTokenNonJSONClientErrorIsFatal
  • TestPollDeviceTokenDefaultsLifetime
  • TestRequestDeviceCode
  • TestDeviceCodePrompt
  • TestSearchPackagesKeepsRegistryFilterOnFallback
  • extra cases in TestLoginMatchesServer

Locally I ran go vet and the targeted -run sets for these tests and for the existing poll, discovery and token-choice tests. I left the full suite to CI. A live login was not exercised.

Not in this PR

These were found in the same review. They are older than #64 and need separate decisions:

  • Push and fetch: jh push/fetch/pull pass an http.extraHeader that is not URL-scoped.
  • Other commands: they still send the stored token to whatever -s names.
  • Discovery latency: discovery waits for the slower probe even when auth.<server> answered first.

🤖 Generated with Claude Code

pankgeorg and others added 7 commits November 5, 2026 22:07
…ng and refresh

pollDeviceToken and refreshToken each posted a form to the dex token
endpoint and decoded the reply; postTokenForm now does both. refreshToken
takes the stored token and resolves the auth host from its issuer itself,
so its two callers no longer call rememberAuthServerFromToken first. The
refresh request now has the same 30 s timeout as polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A poll that got no answer, or a gateway error page instead of JSON,
ended the login even though the device code was still valid. RFC 8628
section 3.5 asks clients to reduce their polling frequency and retry on
connection timeouts. Such polls now double the interval (capped at a
minute) and are retried until the device code expires; the expiry error
then wraps the last failure. A non-JSON answer below 500 still fails at
once, with its HTTP status in the message.

Polling is now always bounded: without expires_in it stops after
15 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…derr

- requestDeviceCode checks the HTTP status and that a device_code came
  back. An error answer (an OAuth error, or a gateway page) used to be
  decoded into an empty response, which printed "Go to  and authorize
  this device" and then failed on the first poll.
- verification_uri_complete is optional in RFC 8628. Without it the
  prompt shows verification_uri and the user code.
- The approval prompt and the missing-refresh-token warning go to
  stderr. jh git-credential get runs the device flow when the stored
  login is for another host, and git reads the helper's stdout as
  credential lines, so the prompt never reached the user and git
  rejected the extra lines.
- The device-code request uses the 30 s timeout client like polling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on them

Both sat above a declaration added just before the function, so godoc
attached them to splitRegistryNames and errRegistryFetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iven

loginMatchesServer normalized both sides, so an explicit URL whose host
has no .com/.dev suffix was expanded before the comparison:
--server https://example.org matched a login stored for
example.org.juliahub.com and got its token. Every caller already passes
the host the request goes to, so only the stored value is normalized now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the registry list cannot be fetched, package search keeps the
--registries names for the REST listing but has no registry IDs. If the
REST listing then failed too, the GraphQL fallback ran without a
registry filter and returned packages from every registry. It now
reports the REST error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pankgeorg

Copy link
Copy Markdown
Member Author

@tanmaykm claude tells me this is marginally improving on #64, if you want to take a look 🙏🏽

@pankgeorg
pankgeorg marked this pull request as ready for review November 5, 2026 20:29

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@lamdor
lamdor merged commit 693eb47 into main Nov 5, 2026
2 checks passed
@lamdor
lamdor deleted the simplify-device-flow branch November 5, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants