Skip to content

Refreshable tokens (2/7): refresher domain, OAuth refresher, storage, and wiring - #14451

Open
babakks wants to merge 7 commits into
babakks/refresh-token-a-foundationfrom
babakks/refresh-token-b-domain
Open

babakks wants to merge 7 commits into
babakks/refresh-token-a-foundationfrom
babakks/refresh-token-b-domain

Conversation

@babakks

@babakks babakks commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

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-lived in PR 3), so on its own this PR changes no observable behavior.

It introduces, bottom to top:

  1. Refreshable credential types and a TokenRefresher interface (plus its generated mock).
  2. An OAuth token refresher in internal/authflow that exchanges a refresh token for a new credential.
  3. Refreshable credential storage in internal/config, persisted only in the per-user slot.
  4. Integration of refreshable credentials into the auth config, including the serialized refresh path (lock, reload, rotate, persist).
  5. An API-request hook that refreshes an expired token before the request goes out.
  6. Factory wiring that injects the refresher so the consumer seams in later PRs can use it.

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, two gh processes sharing an account (or two goroutines on the shared AuthConfig) 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 LoginRefreshable participate in that read-modify-write cycle, so only they take the lock. LoginRefreshable writes into the same rotating-credential slot, so it must serialize against a concurrent refresh as well.

login, switch, and logout are deliberately not wrapped, for three reasons:

  1. They do not consume a single-use secret. They overwrite credentials wholesale, so their worst concurrent outcome is a benign last-writer-wins that still leaves the config in a valid, usable state, which is exactly what a user who is re-authenticating wants anyway.
  2. They are user-initiated and interactive, effectively one-at-a-time. Refresh is the only operation that fires automatically in the background of ordinary commands, so it is the only one realistically prone to firing twice at once.
  3. An honest incremental reason: withFreshConfigForRefresh calls Reload, 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 are refreshLockFile and refreshLockTimeout, and the guard method is withFreshConfigForRefresh.

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 config hosts.<host>.users.<user>.<refreshable key>). There is deliberately no per-host active copy (no keyring "" key, no hosts.<host>.oauth_token) for refreshable credentials.

  1. Single source of truth. The credential rotates on every refresh. Keeping one authoritative copy per user avoids the risk of a stale duplicate in a host-level active slot outranking the freshly rotated token. The active user is surfaced through the hosts.<host>.user pointer, and resolution reads the credential back through the per-user slot, so activation only needs to confirm the per-user record exists (see setActiveCredential).
  2. Login and activation actively clear the host-level active representations for refreshable credentials, so a previously stored non-expiring active token can never shadow a refreshable one.
  3. Consequences for removal. Because there is no host-level active copy, wiping a rejected refreshable credential only needs to clear the per-user records; removeCredentialForUser does not touch the host-level active slot. Likewise persistRefreshedCredential writes back only to the per-user slot it was read from.

Factory wiring: keeping refresh traffic out of the debug log

GH_DEBUG=api prints 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 under GH_DEBUG=api a 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.go is 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 public AuthFlow signature change lands in PR 3 alongside its callers, so this PR's tip stays green.

Suggested reading order: the gh credential/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 interface
  • fix(gh): add generated TokenRefresher mock
  • fix(authflow): add OAuth token refresher
  • fix(config): add refreshable credential storage
  • fix(config): integrate refreshable credentials into auth config
  • fix(api): refresh expired tokens on API requests
  • fix(factory): wire the token refresher into the default factory

Authorship and follow-up

Who wrote this:

  • A human wrote it.
  • An agent wrote it under close human direction.
  • An agent wrote it independently, and no human has guided the implementation beyond the initial prompt.

Who answers review comments:

  • @babakks will read and reply directly.
  • An agent will draft replies and @username will read them before they are posted.
  • Nobody has explicitly committed to replying.

babakks and others added 7 commits September 15, 2026 00:55
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity · 1 Medium severity · 1 Low severity

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.

Comment on lines +33 to +36
// 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.
Comment on lines +235 to +239
// This is required to prevent automatic setting of auth and other headers.
SkipDefaultHeaders: true,
TelemetryDisabler: telemetryDisabler,
}
return api.NewHTTPClient(opts)
Comment thread internal/config/config.go
insecureStorageUsed = true
}

return insecureStorageUsed, c.completeLogin(hostname, username, gitProtocol)
Comment thread api/http_client.go
Comment on lines +177 to +180
// 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
Comment thread internal/config/config.go

// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  1. TokenFromEnvOrConfig: env vars, then hosts.<host>.oauth_token. Empty now.
  2. 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 with no 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 williammartin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

This branch has not been deployed

No deployments
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.

3 participants