Repository navigation
Conversation
Introduce the types the refreshable-credential feature is built on: RefreshStatus and its values, the refreshable fields on Credential, the ErrRefreshTokenInvalid sentinel, and the TokenRefresher interface. Extend the AuthConfig interface with the WithRefresh token getters, LoginRefreshable, and SetTokenRefresher so callers can obtain tokens that gh renews on demand. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Generated with moq for use by tests that exercise refreshable-credential paths. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Add the TokenRefresher implementation that exchanges a refresh token for a fresh access token, along with the helpers that derive a Credential and its expiry from an OAuth access token. Adapt the local login-flow config to the refreshable token getter used by the API client. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Add the storage layer that persists refreshable credentials, reads them back, and renews the access token through the configured TokenRefresher, serializing concurrent refreshes with a file lock. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Wire the refresher into AuthConfig: cache the loaded config, add the WithRefresh token getters and a refreshable branch to TokenForUser, store refreshable credentials on login through LoginRefreshable and completeLogin, and clear the matching keyring entries on logout via setActiveCredential. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Resolve the active token through ActiveTokenWithRefresh when building the authorization header so an expired refreshable credential is renewed before the request is sent. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Construct the authflow-backed TokenRefresher and install it on the AuthConfig so refreshable credentials are renewed at runtime, logging each refresh as a single head line under GH_DEBUG=api. Co-authored-by: Copilot <[email protected]> Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Refresh locking and persistence currently permit token double-spending, stale config overwrites, and incorrect repeated refreshes.
Review effort: Balanced
Findings: 2
Open (4)
What changed in this PR
Introduces the storage, refresh orchestration, API integration, and factory wiring for refreshable OAuth credentials.
Changes:
- Adds refreshable credential types and OAuth refresh support.
- Adds serialized storage and refresh lifecycle handling.
- Refreshes credentials during API requests through factory wiring.
| File | Description |
|---|---|
pkg/cmd/factory/default.go |
Wires the dedicated token refresher client. |
pkg/cmd/factory/default_test.go |
Tests refresher client and injection. |
internal/gh/mock/token_refresher.go |
Adds generated refresher mock. |
internal/gh/gh.go |
Defines refresh domain types and interfaces. |
internal/config/test.go |
Updates isolated config fixtures. |
internal/config/refreshable.go |
Implements refresh storage, locking, and rotation. |
internal/config/refreshable_test.go |
Tests refreshable credential behavior. |
internal/config/migrate_test.go |
Updates config construction syntax. |
internal/config/config.go |
Integrates refreshable credentials into auth config. |
internal/config/auth_config_test.go |
Updates auth-config tests and fixtures. |
internal/authflow/refresh.go |
Implements OAuth token exchange. |
internal/authflow/refresh_test.go |
Tests OAuth refresh responses. |
internal/authflow/flow.go |
Adapts authflow configuration interface. |
internal/attachments/client_test.go |
Adapts attachment test configuration. |
api/http_client.go |
Refreshes credentials before API requests. |
api/http_client_test.go |
Tests headline-only logging and interface changes. |
Files not reviewed (1)
- internal/gh/mock/token_refresher.go: Generated file
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // refreshLockFile names the cross-process lock, in the config directory, that serializes token refreshes. Only the | ||
| // refresh path takes it because refreshing is the sole operation that spends a single-use rotating refresh token; | ||
| // other config mutations such as auth login or logout overwrite credentials wholesale and are safe under | ||
| // last-writer-wins, so a single refresh-scoped lock is enough. |
| // This is required to prevent automatic setting of auth and other headers. | ||
| SkipDefaultHeaders: true, | ||
| TelemetryDisabler: telemetryDisabler, | ||
| } | ||
| return api.NewHTTPClient(opts) |
| insecureStorageUsed = true | ||
| } | ||
|
|
||
| return insecureStorageUsed, c.completeLogin(hostname, username, gitProtocol) |
| // Refreshing is best effort because it happens before actual expiry, and expiry checks can be unreliable due to | ||
| // clock skew. Continue with the returned token and let the API determine whether it is valid. | ||
| cred, _, _ := cfg.ActiveTokenWithRefresh(hostnameInRequest) | ||
| token := cred.Token |
|
|
||
| // Then we'll move the keyring token or insecure token as necessary, only one of the | ||
| // following branches should be true. | ||
| // A refreshable credential is the managed credential, so prefer it. It lives only in the per-user slot and is |
There was a problem hiding this comment.
Switching from a short-lived login back to a regular token with insecure storage leaves go-gh extensions with no token at all.
LoginRefreshable deletes the user's non-expiring token, but plain Login doesn't delete the user's refreshable record. So after the switch, the user has both. When Login -> completeLogin -> activateUser reaches this block, it finds the old refreshable record and returns early, so the new token never gets copied into hosts.<host>.oauth_token.
I hit this on the stack tip:
gh auth login --short-lived
gh auth refresh --insecure-storage # without --short-lived
Afterwards hosts.yml has users.williammartin.oauth_token, but no hosts.github.com.oauth_token.
gh itself still works, because TokenForUser checks the per-user oauth_token before the refreshable record. In other words, this block and TokenForUser disagree about which credential is current. Extensions don't work, because go-gh's TokenForHost does:
TokenFromEnvOrConfig: env vars, thenhosts.<host>.oauth_token. Empty now.gh auth token --secure-storage --hostname <host>: resolves the per-user config token. That token isn't refreshable, so the command reads only the keyring, misses, and fails withno oauth token found for github.com.
Before this PR, activateUser always copied the token into the host slot, so with insecure storage go-gh never had to call gh at all.
There's also a cleanup problem: the old refresh token stays on disk (plaintext in hosts.yml with insecure storage) until logout. And if the per-user keyring read ever fails transiently, TokenForUser falls through to that old record, attempts a refresh with it, and if the server rejects it, removeCredentialForUser deletes the valid token too.
I think the fix is for Login to delete the user's refreshable record (both the keyring and config forms), mirroring how LoginRefreshable deletes the non-expiring token. That keeps a user from ever having both, and this block falls through to the non-expiring path again. It would also be worth making this block and TokenForUser check in the same order, so any mixed state that slips through still resolves to the same credential.
williammartin
left a comment
There was a problem hiding this comment.
Looking at the log output:
* Request at 2026-10-06 13:37:46.279436 +0200 CEST m=+0.100466959
* Request to https://github.com/login/oauth/access_token
* Request took 371.450167m
It does make me wonder whether we are going to have sufficient information to debug when something goes wrong. When a user says "I experienced an auth issue" how do we understand the state they have ended up in. What if there was a refresh during their command. Maybe we need to persist some information. No specific ideas, just something noodling on.



Part of #14449. Based on #14450 (foundation).
Description
The domain and orchestration layer for refreshable tokens: the machinery that acquires, stores, and refreshes a short-lived credential, and the factory wiring that injects the refresher. It is still not reachable by a user, because nothing yet requests a refreshable token (that arrives with
--short-livedin PR 3), so on its own this PR changes no observable behavior.It introduces, bottom to top:
TokenRefresherinterface (plus its generated mock).internal/authflowthat exchanges a refresh token for a new credential.internal/config, persisted only in the per-user slot.How did you test this change?
This feature is verified end to end as a whole rather than per PR. End-to-end tests should pass.
Key points
The refresh lock: what it guards and why its scope is narrow
The cross-process config lock (
withFreshConfigForRefresh) exists to protect a single-use, rotating refresh token from double-spend. A rotating refresh token is a single-use secret: if two actors present the same refresh token to the OAuth server, one exchange succeeds and the other is rejected, and many providers respond to reuse by revoking the entire token family, logging the user fully out with no recovery except re-authenticating. With today's extensive use of agents and concurrent workflows, twoghprocesses sharing an account (or two goroutines on the sharedAuthConfig) can realistically hit an expired token at the same moment, read the same refresh token from disk, and both POST it. The lock plus a reload guarantees each refresh observes the latest on-disk token before spending it.Only the refresh path and
LoginRefreshableparticipate in that read-modify-write cycle, so only they take the lock.LoginRefreshablewrites into the same rotating-credential slot, so it must serialize against a concurrent refresh as well.login,switch, andlogoutare deliberately not wrapped, for three reasons:withFreshConfigForRefreshcallsReload, which discards unwritten in-memory state. Wrapping the lifecycle methods would change their contract and require reworking the tests that build state in memory rather than persisting via disk. We deliberately deferred that broader wrapping.A refresh racing a manual login is possible but harmless: both write valid credentials, last-writer-wins yields a working state, and the only cost is a possible re-auth, never a revoked token family. So serializing refresh-against-refresh (in-process mutex plus cross-process flock) covers the only race that can corrupt the rotating-token chain; guarding the other operations would add contention for no additional safety.
To make the scope obvious in the code, the lock artifacts are refresh-specific rather than general-config: the lock file is
refresh.lock, the constants arerefreshLockFileandrefreshLockTimeout, and the guard method iswithFreshConfigForRefresh.Storage: refreshable credentials live only in the per-user slot
Unlike non-expiring tokens, a refreshable credential is persisted only in the per-user slot (keyring service
gh:_refreshable:<host>under the username key, or confighosts.<host>.users.<user>.<refreshable key>). There is deliberately no per-host active copy (no keyring""key, nohosts.<host>.oauth_token) for refreshable credentials.hosts.<host>.userpointer, and resolution reads the credential back through the per-user slot, so activation only needs to confirm the per-user record exists (seesetActiveCredential).removeCredentialForUserdoes not touch the host-level active slot. LikewisepersistRefreshedCredentialwrites back only to the per-user slot it was read from.Factory wiring: keeping refresh traffic out of the debug log
GH_DEBUG=apiprints a verbose trace of HTTP traffic, but the token-refresh exchange is deliberately kept out of it. The factory builds the refresher on a dedicated HTTP client (tokenRefresherHttpClientFunc) configured for headline-only logging, so even underGH_DEBUG=apia refresh emits at most a single head line (the token endpoint URL and its timing), never the request and response bodies. Those bodies carry live secrets: the refresh token going out and a freshly issued access token coming back. Debug logs tend to be long, and users routinely paste them into issues or share them while troubleshooting without scrubbing every line, so dumping a full refresh trace would leak rotating credentials into a trail that outlives the exchange. Keeping the refresh exchange headline-only is a deliberate privacy-hardening choice, not an oversight. A testing-only override,GH_DEBUG_REFRESH_TOKEN, forces the verbose refresh trace for local debugging, and it is removed in the polish PR.Notes for reviewers
internal/authflow/flow.gois touched here and again in PR 3. In this PR only the local api-client config adapter is updated (needed by the API refresh hook). The publicAuthFlowsignature change lands in PR 3 alongside its callers, so this PR's tip stays green.Suggested reading order: the
ghcredential/refresher types, then the authflow refresher, then config storage, then the auth-config integration (where the lock lives), then the API hook, then factory wiring.Commits (review in order):
fix(gh): add refreshable credential types and token refresher interfacefix(gh): add generated TokenRefresher mockfix(authflow): add OAuth token refresherfix(config): add refreshable credential storagefix(config): integrate refreshable credentials into auth configfix(api): refresh expired tokens on API requestsfix(factory): wire the token refresher into the default factoryAuthorship and follow-up
Who wrote this:
Who answers review comments: