Surface keyring access failures from ActiveToken#13318
Open
Iron-Ham wants to merge 1 commit into
Open
Conversation
bfb35af to
9d71383
Compare
When ActiveToken's keyring lookup fails with a non-ErrNotFound error (typically *keyring.TimeoutError on macOS Keychain), the error was silently discarded and AddAuthTokenHeader.RoundTrip then sent the request without an Authorization header. Users saw a confusing HTTP 401 (REST) or HTTP 403 "API rate limit exceeded for <IP>" (GraphQL) with no signal that the local keyring was the cause. Add ActiveTokenWithError to gh.AuthConfig that surfaces these errors. Refactor ActiveToken as a thin wrapper that calls ActiveTokenWithError and discards the error so existing callers keep their signature. Require the new method on api/http_client.go's tokenGetter interface and update RoundTrip to fail the request with the resolution error rather than send unauthenticated. When the user-scoped keyring lookup fails with a real error and the legacy unkeyed fallback then returns ErrNotFound, preserve the original error rather than letting the fallback's "not found" mask it. Otherwise a timeout or permission failure on the canonical lookup would be reported as if no token were configured. ErrNotFound continues to be the legitimate "no token configured for this host" signal and is not surfaced as an error so genuinely anonymous requests against unconfigured hosts keep working. Fixes cli#13317
9d71383 to
5213965
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #13317.
Summary
gh apiandgh api graphqlwere silently sending requests without anAuthorizationheader whengh's keyring lookup failed — typically when the 3-second timeout ininternal/keyring/keyring.gofired against a macOS Keychain entry whose ACL had drifted (e.g. an entry written by an olderghthat now requires an interactive approval the non-TTY child process can't surface). Users saw onlyHTTP 401(REST) orHTTP 403 "API rate limit exceeded for <IP>"(GraphQL) with no signal that the local keyring was the cause.The bug was a silent error discard at two layers:
(*config.AuthConfig).ActiveTokenswallowed keyring errors fromTokenFromKeyringForUser/TokenFromKeyringand returned("", source).AddAuthTokenHeader.RoundTripthen saw an empty token and proceeded with no header — indistinguishable from a legitimate anonymous request.What changed
ActiveTokenWithError(hostname) (token, source, err)togh.AuthConfigand*config.AuthConfigthat surfaces non-ErrNotFoundkeyring errors.(*AuthConfig).ActiveTokenas a thin wrapper that callsActiveTokenWithErrorand discards the error, preserving its existing signature for every current caller.ActiveTokenWithErroronapi/http_client.go's localtokenGetterinterface; updated thecfgadapter ininternal/authflow/flow.goand thetinyConfigtest mock to match.AddAuthTokenHeader.RoundTripnow callsActiveTokenWithError. On non-nil error it fails the request with that error; on nil error + empty token it proceeds anonymously (preserves anonymous-endpoint behavior); on nil error + non-empty token it attaches theAuthorizationheader as before.keyring.ErrNotFoundcontinues to be the legitimate "no token configured for this host" signal and is not surfaced as an error, so anonymous requests against unconfigured hosts keep working unchanged.What stays the same
gh.AuthConfig.ActiveTokensignature and behavior for every existing caller.tokenGetter(e.g.stubAuthConfiginpkg/cmd/attestation/trustedroot/trustedroot_test.go) —stubAuthConfigembedsconfig.AuthConfigso it inherits the new method automatically.gh api zenagainst a host with no configured user still proceeds without anAuthorizationheader).Alternatives considered
Opt-in type-assertion shim (avoid changing
gh.AuthConfig)Initially this PR added an unexported
errorTokenGetterinterface inapi/http_client.gothatRoundTripwould type-assert against, with*config.AuthConfigopportunistically satisfying it. The advantage was zero churn togh.AuthConfig— no interface change, no mock updates for downstream consumers.Rejected because the new error-aware behavior would only fire by accident of static type propagation, the contract would be untestable from the interface seam (any future code constructing
HTTPClientOptionsfrom agh.AuthConfigvalue rather than the concrete type would silently get the legacy path), and an opt-in shim is brittle for a fix to a silent-failure bug. AddingActiveTokenWithErrorto the interface makes the contract explicit and verifiable.Naming:
ActiveTokenEvs.ActiveTokenWithErrorvs.ResolveActiveTokenActiveTokenE(single-letterEsuffix) is concise and used in some Go projects, but has no precedent in cli/cli — would be inventing a convention.ResolveActiveTokendiscriminates well fromActiveToken, but pairs awkwardly with the existing method-on-noun naming throughoutgh.AuthConfig(ActiveUser,TokenFromKeyring, etc.).Chose
ActiveTokenWithErrorbecause it composes naturally withActiveToken(clear sibling relationship), is self-documenting in error-checking call sites, and matches the existing explicit-suffix precedent in this codebase (e.g.keyring.MockInitWithErrorininternal/keyring/keyring.go).Logging from inside
ActiveToken(surface error without signature change)A smaller-footprint alternative: keep
ActiveTokenunchanged but write a warning toos.Stderrwhen both keyring lookups fail with non-ErrNotFounderrors. The user would see a "warning: keyring access failed" line followed by the downstream 401/403, which is at least diagnosable.Rejected because (1) logging from a config-layer getter is the wrong architectural seam — it doesn't compose with structured loggers or with cli/cli's
--verboseplumbing, (2) the request still goes out unauthenticated, so scripts and CI consumers still get misleading exit codes, and (3) it leaves the underlying contract bug in place for every future caller. Returning the error is the right fix; logging would be a band-aid.Marking
ActiveTokenas// Deprecated:in this PRCould be added as a one-line annotation on the existing method to steer new code toward
ActiveTokenWithError.Deferred to a follow-up PR because adding the marker now would generate
SA1019warnings on every existing call site (pkg/cmd/auth/status/status.go:238,pkg/cmd/auth/refresh/refresh.go:178,229,pkg/cmd/auth/token/token.go:81,pkg/cmd/auth/shared/writeable.go:10,pkg/cmd/config/get/get.go:58, plus internal usages) without giving them a migration path in the same PR. Better to land this fix first, then a follow-up that migrates the call sites and adds the marker together. Happy to do both in one PR if maintainers prefer.Follow-ups (not in this PR)
Other call sites of
ActiveTokenwould benefit from migrating toActiveTokenWithErrorso the same class of silent failure can't recur in those code paths. Left for a follow-up to keep this PR focused:pkg/cmd/auth/status/status.go:238—gh auth statuscan mis-report keyring failures as "no token"pkg/cmd/auth/refresh/refresh.go:178,229pkg/cmd/auth/token/token.go:81pkg/cmd/auth/shared/writeable.go:10pkg/cmd/config/get/get.go:58Adding
// Deprecated:toActiveTokenis also deferred to that follow-up — adding it now would generateSA1019warnings on every existing call site without giving users a migration path.Test plan
go vet ./...clean.go test ./... -shortpasses (full suite).internal/config/auth_config_test.go:TestActiveTokenWithErrorSurfacesKeyringFailure— keyring error is wrapped and returned.TestActiveTokenSilentlyDiscardsKeyringFailure—ActiveToken(the wrapper) preserves its no-error contract.TestActiveTokenWithErrorDoesNotSurfaceErrNotFound—ErrNotFoundreturns(token="", err=nil).TestActiveTokenWithErrorReturnsEnvTokenWithoutKeyring— env-var path short-circuits keyring entirely.TestActiveTokenWithErrorReturnsKeyringToken— successful keyring resolution returns(token, "keyring", nil).TestAddAuthTokenHeaderResolutionPathsinapi/http_client_test.gocovering: error fails the request, resolved token is attached, empty token without error proceeds anonymously.gh api userreturnsHTTP/2 200, x-ratelimit-limit: 5000, resource: core; same for GraphQL).