Visitar URL original
Refreshable tokens (4/7): gh auth token refresh and secure-storage handling by babakks · Pull Request #14453 · cli/cli · GitHub
Skip to content

Refreshable tokens (4/7): gh auth token refresh and secure-storage handling - #14453

Open
babakks wants to merge 1 commit into
babakks/refresh-token-c1-login-refresh-gitcredentialfrom
babakks/refresh-token-c2-auth-token
Open

babakks wants to merge 1 commit into
babakks/refresh-token-c1-login-refresh-gitcredentialfrom
babakks/refresh-token-c2-auth-token

Conversation

@babakks

@babakks babakks commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Part of #14449. Based on #14452 (login, refresh, git credential).

Description

Makes gh auth token refresh a short-lived token before printing it, and handles the hidden --secure-storage flag correctly for refreshable credentials.

gh auth token is not only a user-facing command; it is also how go-gh (and therefore every gh extension) fetches a stored token. go-gh invokes it with a hidden --secure-storage flag, whose historical meaning is "look only in the OS keyring, ignore environment variables and the plain config file." That contract made sense when the only tokens gh stored were non-expiring: go-gh could read a config-file token itself, so --secure-storage was just asking for the keyring copy specifically.

Refreshable tokens break that assumption in two ways: they are stored under a separate config key (and a separate keyring service) that go-gh does not know about, so a go-gh caller cannot obtain a refreshable token by any means other than shelling out to gh auth token; and gh, not the caller, owns their lifecycle (expiry, rotation, re-approval), so handing a caller a refreshable token means gh must first make it valid.

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

Because the command now varies along two independent axes, whether to refresh (--no-refresh) and whether to restrict to secure storage (--secure-storage), the run function is written as an explicit four-case switch rather than a cascade of conditionals. The four cases are:

  1. Neither flag (the common path): resolve the active token from wherever it lives and refresh it if needed.
  2. --no-refresh only: return the stored active token as is, still surfacing a refreshable token's current access token, but never contacting the token endpoint.
  3. --secure-storage only (the go-gh compatibility path): if the resolved credential is refreshable or was just refreshed, return it regardless of where it is stored; otherwise fall back to the original strict behavior and read a non-refreshable token straight from the keyring.
  4. --secure-storage --no-refresh: a rare combination with no known caller; honor --secure-storage strictly and return the token only when it actually came from the keyring.

Why case 3 diverges from the historical --secure-storage semantics

The tempting simplification is to treat --secure-storage as an absolute "keyring only" filter in every case. That would be wrong for refreshable tokens: a refreshable token stored in the plain config file would then be invisible to go-gh even though go-gh has no other way to reach it, so every extension would break for users on short-lived credentials whose token happens to live in the config file. So case 3 deliberately ignores the keyring-only constraint when the token to return is refreshable (or was just refreshed): gh owns the lifecycle, the caller cannot get the token any other way, and dropping a freshly minted token merely because of where it is stored would be a silent failure.

We keep the divergence as narrow as possible. When the credential is not refreshable, case 3 falls straight back to the original strict keyring read, so non-refreshable callers see exactly the old behavior. Case 4 (--secure-storage --no-refresh) is stricter still: with refresh declined there is no lifecycle to honor, so we respect --secure-storage literally and return only a genuinely keyring-sourced token.

We are intentionally not proposing to remove or rename --secure-storage here. The flag is part of the go-gh contract and other callers may rely on it; the point is only to record why the handling is more intricate than a single boolean filter and why case 3 knowingly departs from the literal meaning of the flag for refreshable tokens.

Notes for reviewers

The four-case switch in the run function is the heart of the change; read it against the --secure-storage contract described above, and check that the non-refreshable fallbacks in cases 3 and 4 reproduce the exact prior behavior.

Commit:

  • fix(auth token): refresh short-lived tokens before printing

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.

Refresh an expired or near-expiry refreshable token before printing it, and add
--no-refresh to opt out. Fail with a re-authentication notice when the refresh
token is rejected. Under --secure-storage a refreshable token is returned
regardless of where it is stored, since gh owns its lifecycle and a go-gh based
caller cannot obtain it otherwise; the strict keyring-only behavior is preserved
for non-refreshable tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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

The secure-storage paths regress legacy host-level keyring lookup when an active user is recorded.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Updates gh auth token to refresh short-lived credentials while preserving secure-storage compatibility.

Changes:

  • Adds --no-refresh and automatic token refresh.
  • Implements four secure-storage/refresh combinations.
  • Expands unit coverage for token sources and refresh outcomes.
File Description
pkg/​cmd/​auth/​token/​token.go Adds refresh and secure-storage selection logic.
pkg/​cmd/​auth/​token/​token_test.go Tests flags, storage modes, and refresh outcomes.

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

val = cred.Token
} else {
cred, _ := authCfg.TokenForUser(hostname, opts.Username)
val = getNonRefreshableTokenFromKeyring(authCfg, hostname, user)

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.

I don't think this is really an issue. That is incredibly old code.

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.

It does highlight that the unkeyed-slot fallback in ActiveToken is dead weight now though. We should clean that legacy path up in a follow-up, at which point this and ActiveToken will agree again.

} else {
if opts.Username == "" {
val = authCfg.ActiveToken(hostname).Token
if cred.IsRefreshable() || refreshStatus == gh.RefreshStatusDone {

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.

Question

Under what circumstances would cred.IsRefreshable be false but refreshStatus == gh.RefreshStatusDone be true?

val = cred.Token
} else {
cred, _ := authCfg.TokenForUser(hostname, opts.Username)
val = getNonRefreshableTokenFromKeyring(authCfg, hostname, user)

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.

It does highlight that the unkeyed-slot fallback in ActiveToken is dead weight now though. We should clean that legacy path up in a follow-up, at which point this and ActiveToken will agree again.

@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.

I'm still pretty unsure about --secure-storage refreshing tokens stored insecurely. I think there's a reasonable case to be made for the user opting into --short-lived, requiring extension authors to upgrade. Pretty torn on the right option here.

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