Skip to content

Refreshable tokens (1/7): foundation credential model, storage migration, and file lock - #14450

Open
babakks wants to merge 5 commits into
trunkfrom
babakks/refresh-token-a-foundation
Open

babakks wants to merge 5 commits into
trunkfrom
babakks/refresh-token-a-foundation

Conversation

@babakks

@babakks babakks commented Sep 15, 2026

Copy link
Copy Markdown
Member

Part of #14449.

Description

Foundation for refreshable (short-lived) OAuth tokens. This PR carries no user-facing behavior; it prepares the ground so the later PRs can add the refresh machinery without re-threading call sites or churning unrelated code.

It does three things:

  1. Introduces a single gh.Credential type and migrates the auth storage interface (and its callers) to speak that type instead of a bare token string.
  2. Bumps cli/go-gh and cli/oauth to pick up the refreshable-credential support this feature builds on.
  3. Adds a blocking, timeout-bounded file-lock acquisition to internal/flock, used later to serialize single-use refresh-token exchanges across processes.

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

  • Why a gh.Credential type now. Until now the auth storage layer spoke bare token strings, which is all a non-expiring token needs. A refreshable credential is more than a string: it carries a refresh token and expiry timestamps that must travel together with the access token through every layer that reads or writes it. Introducing the struct and migrating the interface in one mechanical, behavior-preserving step here means the later PRs only add fields and behavior, rather than re-plumbing every call site while also introducing new logic. It keeps the risky, reviewable logic changes out of a large mechanical diff.
  • Why the blocking flock. The refresh path needs cross-process mutual exclusion so two gh processes cannot spend the same single-use refresh token at once. The existing lock primitive was a non-blocking try-lock, which would force a refresh to fail immediately if a peer held the lock. This PR adds a blocking acquire with a timeout: a refresh waits briefly for a peer to finish (and then observes the peer's rotated credential on reload) instead of failing, while the timeout guarantees it can never hang forever. The timeout value and the refresh-specific naming land with the refresh path in PR 2.
  • The dependency bump is an interim pin. Before final merge we will tag and release cli/go-gh and cli/oauth, repin to the released versions, and re-run go mod tidy. This is tracked in the envelope issue.

Notes for reviewers

Start with the gh.Credential type and the storage-interface change, then the caller migration (mechanical), then the flock timeout addition. The migration should not change any observable behavior; if you spot a behavioral difference, that is a bug.

Commits (review in order):

  • build(deps): bump go-gh and oauth for refreshable credentials
  • fix(gh): introduce gh.Credential and switch auth storage interface to it
  • fix(config): migrate auth storage layer and callers to gh.Credential
  • fix(flock): add blocking lock acquisition with timeout

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 4 commits September 15, 2026 00:55
Bump github.com/cli/go-gh/v2 and github.com/cli/oauth to the pseudo-versions
that expose the refreshable-credential and OAuth refresh APIs used by the
token refresh support that follows.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Add the gh.Credential type, its IsRefreshable helper, and the token source
constants, and change the AuthConfig interface so ActiveToken and TokenForUser
return a Credential instead of a (token, source) pair. This is the foundational
type the refreshable-credential work builds on.

Also un-ignore the internal/gh directory by anchoring the gh ignore rule to the
repo root (/gh), so the built binary is still ignored but package files are not.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Update the config AuthConfig implementation (ActiveTokenType, ActiveToken,
HasActiveToken, TokenForUser, SwitchUser) and every caller and test double to
the Credential-returning API introduced in the previous commit. This is a
mechanical, behavior-preserving migration with no refreshable logic yet.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Extend the file lock with a blocking acquire that retries until it succeeds or a
timeout elapses, used to serialize concurrent token-refresh attempts.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Copilot AI balanced review requested due to automatic review settings September 15, 2026 01:28
@babakks
babakks requested a review from a team as a code owner September 15, 2026 01:28
@babakks
babakks requested a review from niik September 15, 2026 01:28
@babakks
babakks added this pull request to stack #14457 September 15, 2026 01:31

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

The exported HTTP client contract becomes unusable externally, and lock acquisition can succeed after context cancellation.

Get a fresh assessment by requesting another Copilot review.

Review tier: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Introduces the credential and locking foundations needed for refreshable OAuth tokens without intentionally changing user-facing behavior.

Changes:

  • Adds gh.Credential and migrates authentication storage consumers.
  • Adds timeout-bounded file-lock acquisition and tests.
  • Updates OAuth dependencies and related fixtures.
File Description
.gitignore Limits the gh ignore rule to the repository root.
api/​http_client.go Migrates HTTP authentication to credentials.
api/​http_client_test.go Updates HTTP client test configuration.
go.mod Pins refresh-support dependency revisions.
go.sum Updates dependency checksums.
internal/​attachments/​client_test.go Updates attachment test authentication.
internal/​authflow/​flow.go Adapts OAuth flow configuration.
internal/​config/​auth_config_test.go Migrates authentication storage assertions.
internal/​config/​config.go Returns credentials from authentication storage.
internal/​flock/​flock.go Adds blocking lock acquisition.
internal/​flock/​flock_test.go Tests blocking, timeout, and error behavior.
internal/​gh/​gh.go Defines the credential model and interface.
internal/​ghcmd/​cmd.go Adapts authentication recovery logic.
pkg/​cmd/​agent-task/​agent_task.go Uses credential fields and source constants.
pkg/​cmd/​agent-task/​shared/​capi.go Extracts the CAPI access token.
pkg/​cmd/​auth/​gitcredential/​helper.go Migrates Git credential lookup.
pkg/​cmd/​auth/​gitcredential/​helper_test.go Updates the credential-helper fake.
pkg/​cmd/​auth/​logout/​logout_test.go Updates logout token assertions.
pkg/​cmd/​auth/​refresh/​refresh.go Migrates refresh token lookups.
pkg/​cmd/​auth/​refresh/​refresh_test.go Updates refresh assertions.
pkg/​cmd/​auth/​shared/​writeable.go Uses credential source metadata.
pkg/​cmd/​auth/​status/​status.go Migrates status credential lookup.
pkg/​cmd/​auth/​token/​token.go Migrates token output lookup.
pkg/​cmd/​config/​get/​get.go Extracts tokens from credentials.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread api/http_client.go

type config interface {
ActiveToken(string) (string, string)
ActiveToken(string) gh.Credential

@williammartin williammartin Oct 2, 2026 •

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.

Comment thread internal/flock/flock.go
Comment thread internal/config/config.go
}

if user, err := c.ActiveUser(hostname); err == nil {
if credential, err := c.TokenForUser(hostname, user); err == nil {

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.

This isn't quite behaviour-preserving. Previously the active-user lookup only checked the per-user keyring slot (TokenFromKeyringForUser). Going through TokenForUser also checks hosts.<host>.users.<user>.oauth_token in the config file, ahead of the unkeyed keyring fallback.

I checked it with a scratch test: active user u, users.u.oauth_token: per-user-config, an unkeyed keyring token legacy-keyring, and no host-level oauth_token:

trunk: token="legacy-keyring"  source="keyring"
PR:    token="per-user-config" source="oauth_token"

It should be rare in practice because activateUser always mirrors the per-user insecure token into the host-level slot, and the new result is arguably more correct. But the description says any behavioural difference is a bug, so can we either call this out as intentional (I assume it's groundwork for the refreshable slot in #14451) with a test pinning it, or keep the old lookup order here?

Comment thread internal/gh/gh.go
// ActiveToken retrieves the active credential for the given hostname, searching environment variables, general
// configuration, and finally encrypted storage. It returns the last stored token as-is, including the current
// access token of a refreshable credential, and never contacts the token endpoint to refresh it. A caller that
// needs a token valid for API calls should use ActiveTokenWithRefresh instead.

@williammartin williammartin Oct 2, 2026 •

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.

ActiveTokenWithRefresh doesn't exist at this point in the stack. Same for TokenForUserWithRefresh below at L246, and the matching comments in internal/config/config.go (L257, L569). Fine if this only ever merges with #14451, but if the PRs land separately these docs point at nothing.

Comment thread internal/gh/gh.go
TokenSourceOAuthToken = "oauth_token"
// TokenSourceRefreshableOAuthToken indicates the token was read from the refreshable_oauth_token entry in the
// config file, which holds a refreshable credential and is kept distinct from the non-expiring oauth_token entry.
TokenSourceRefreshableOAuthToken = "refreshable_oauth_token"

@williammartin williammartin Oct 2, 2026 •

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.

Nothing in this PR uses TokenSourceRefreshableOAuthToken.

Resolve go.mod/go.sum conflicts by keeping trunk's dependency versions
and re-pinning go-gh to v2.16.2-0.20260914233729-2ce792192e43. The old
pseudo-version was v2.16.1-0.*, which sorts below the v2.16.1 release.
The cli/oauth pin is unchanged.

Co-authored-by: Copilot App <[email protected]>

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