Skip to content

Refreshable tokens (3/7): short-lived login, refresh, and git credential path - #14452

Open
babakks wants to merge 6 commits into
babakks/refresh-token-b-domainfrom
babakks/refresh-token-c1-login-refresh-gitcredential
Open

babakks wants to merge 6 commits into
babakks/refresh-token-b-domainfrom
babakks/refresh-token-c1-login-refresh-gitcredential

Conversation

@babakks

@babakks babakks commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Part of #14449. Based on #14451 (domain and logic).

Description

This is where refreshable tokens become reachable by a user. It adds the --short-lived opt-in to gh auth login, stores the resulting refreshable credential, makes gh auth refresh preserve or acquire one, and teaches the git credential helper to serve short-lived tokens safely.

It lands, bottom to top:

  1. An AuthResult from the auth flow that optionally carries a refreshable credential.
  2. The rule that refreshable credentials are never written into a non-gh git credential helper.
  3. Storing refreshable credentials during login.
  4. The gh auth login --short-lived flag itself.
  5. gh auth refresh preserving an existing refreshable credential or acquiring one.
  6. The git credential helper refreshing tokens and marking them non-cacheable for git.

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

git 2.46 and not caching short-lived tokens in git

When short-lived (refreshable) credentials are used for git operations through the gh credential helper (gh auth git-credential), git 2.46 or newer is recommended. git 2.46 introduced the authtype credential capability, which lets a helper mark a credential as ephemeral. gh uses that to emit the token as authtype=Basic with an ephemeral credential field, so a caching helper (for example git-credential-cache) refuses to store it. This prevents a short-lived token from being cached and then reused after it has been refreshed or replaced out of band (for example by gh auth refresh or gh auth login), which would otherwise surface as a stale, rejected token.

On older git the capability is unavailable, so gh cannot mark the token non-cacheable. In that case gh still returns the token (as username/password) and prints a one-line warning on stderr advising the user to upgrade git or avoid a credential caching helper. Non-refreshable tokens are unaffected, so users who do not opt into --short-lived see no change and have no new requirement.

Framing for the changelog and release notes: this is a recommendation tied specifically to the --short-lived opt-in, not a hard requirement for all gh git operations; the risk only materializes when a credential caching helper is chained in front of gh, which is the user's own git configuration and an uncommon setup; and gh detects the capability from git at runtime, so there is nothing to configure and upgrading git is the only action.

Never storing a refreshable token in a non-gh git helper

During gh auth login and gh auth refresh, gh may offer to configure git to use the obtained credential. This external-store path (GitCredentialFlow.Setup calling Updater.Update, i.e. git credential approve) only runs when all of these hold: the git protocol is https (not ssh); a credential helper other than gh is already configured for the host; the command was run interactively; and the user answered yes to the "Authenticate Git with your GitHub credentials?" prompt.

Historically this was safe because the stored token never expired. With refreshable (short-lived) tokens it is no longer safe: a third-party helper has no knowledge of gh's refresh mechanism, so it would persist a soon-stale secret and keep serving it after expiry, and there is no path for gh to re-approve the rotated token into that external helper. Git operations would then fail with a stale, rejected token hours after a login that appeared to succeed.

New behavior: when the issued credential is refreshable and a non-gh helper is configured, gh does not store the token in that helper. It rejects any existing credential for the host (so the problem surfaces immediately rather than silently after expiry) and prints a warning naming the host and the exact remedy, gh auth setup-git --hostname HOST, which reconfigures git to use gh as the credential helper. Once gh is the helper, the ephemeral/refresh path above handles short-lived tokens correctly. Non-refreshable tokens keep the previous behavior unchanged.

Options considered for the "refreshable token + non-gh helper" case:

  1. Reject the existing credential and warn, storing nothing, with a hint to run gh auth setup-git --hostname HOST. (Chosen.)
  2. Store the expiring token in the external helper anyway, with a warning that it will expire and the helper cannot refresh it.
  3. Explain the problem, prompt the user, and offer to configure gh as the helper for the host; if yes proceed with that, otherwise reject and warn.
  4. On every token refresh, write the new access token back into the non-gh helper to keep it fresh.

Why option 1: it is the simplest change with the fewest side effects, and it stays consistent with the ephemeral design used elsewhere (we deliberately avoid caching a refreshable token in any store that cannot refresh it). It does not persist a short-lived secret in a store that cannot manage it, so the whole feature has one coherent stance: refreshable tokens live only where gh can rotate them. Rejecting the existing credential makes the incompatibility surface immediately, at login/refresh time, in front of an interactive user who can act on the hint. And the remedy is a single existing command; in practice most users who let gh manage git already have gh configured as their helper, so this path is uncommon to begin with.

Why the others were rejected: option 2 guarantees the exact failure we are trying to prevent (a token that works at first and then silently breaks on expiry, with no mechanism to re-approve the refreshed token). Option 3 is the nicest UX and a reasonable future enhancement, but it is more code and test surface for what is functionally "run ConfigureOurs inline," which is exactly what gh auth setup-git already does. Option 4 is both more complex and arguably sneaky: silently writing to a user's third-party credential helper on every background refresh is surprising behavior, and the refresh happens deep in the HTTP path without git-client access or user consent in scope.

Implementation seam: the decision lives in GitCredentialFlow.Setup (policy), which returns an undecorated warning string; the callers (login_flow.go, refresh.go) own presentation and stdio, prefixing the warning icon and printing to stderr. The reject-only operation is a new Updater.RejectExisting method, factored out of Updater.Update.

No GHES / feature-detection gate

Refreshable tokens are requested by adding the offline_access scope during the OAuth device flow. A server that does not support refreshing simply ignores that scope and returns an ordinary non-expiring token, exactly as before. gh's login path was written to accept whatever kind of token the server returns and to treat "not refreshable" as the normal, non-expiring case, so there is nothing version-specific to detect. Older GHES instances therefore keep working unchanged with no capability probe and no // TODO <cleanup> gate, and none is added.

Notes for reviewers

internal/authflow/flow.go gets its public AuthFlow signature change here (PR 2 only adjusted the local adapter). Good starting point: the AuthResult change, then the non-gh-helper policy in auth/shared (the most consequential decision in this PR), then the --short-lived flag, then the git credential helper.

Commits (review in order):

  • fix(authflow): return an AuthResult with an optional refreshable credential
  • fix(auth/shared): do not store refreshable credentials in external git helpers
  • fix(auth/shared): store refreshable credentials during login
  • fix(auth login): add --short-lived flag
  • fix(auth refresh): preserve or acquire refreshable credentials
  • fix(auth gitcredential): refresh tokens and mark them non-cacheable for git

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 6 commits September 15, 2026 00:55
…ential

Change AuthFlow to accept a requestRefreshToken preference and return an
AuthResult. When the server issues a refresh token the result carries a
refreshable Credential whose expiry is anchored to the instant before the flow
began; otherwise it carries a plain token.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
…t helpers

An external git credential helper cannot refresh a short-lived token and would
keep serving it after it expires. Teach GitCredentialFlow.Setup to reject any
existing credential and return a warning in that case instead of storing one,
and extract Updater.RejectExisting so the rejection can be reused.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Request a short-lived token when the user prefers it, store the resulting
refreshable credential through LoginRefreshable, and report whether the host
issued a short-lived token. The authorization step is indirected so tests can
stub it.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Add a --short-lived flag that asks the host for a refreshable credential gh
renews automatically. It is a preference, not a guarantee, and is mutually
exclusive with --with-token.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
Add a --short-lived flag to gh auth refresh and store a refreshable credential
when the host issues one. Warn up front when refreshing an existing short-lived
token without --short-lived, since that downgrades it to a non-expiring token.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
…or git

Resolve the token through ActiveTokenWithRefresh so an expired short-lived token
is renewed before it is handed to git, and fail with a re-authentication notice
when the refresh token is rejected. Under git's authtype capability mark a
refreshable token ephemeral so a chained caching helper will not persist it, and
warn on older git that cannot express this.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 2dc3f23c-61a6-43a7-85cd-90aa872a7fc6
case result.Refreshable != nil:
fmt.Fprintf(opts.IO.ErrOut, "%s Received short-lived refreshable token\n", cs.SuccessIcon())
case wasRefreshable && !opts.ShortLived:
fmt.Fprintf(opts.IO.ErrOut, "%s Original token was short-lived; pass --short-lived to preserve it\n", cs.WarningIcon())

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 saw this getting printed twice during gh auth refresh:

➜  williammartin-supreme-umbrella git:(babakks/refresh-token-e2e) ./bin/gh auth refresh --insecure-storage
? What account do you want to refresh auth for? github.com
! Original token was short-lived; pass --short-lived to preserve it

! One-time code (536C-8241) copied to clipboard
Press Enter to open https://github.com/login/device in your browser...
✓ Authentication complete.
! Original token was short-lived; pass --short-lived to preserve it


if !opts.ResetScopes {
if oldToken := authCfg.ActiveToken(hostname).Token; oldToken != "" {
if oldScopes, err := shared.GetScopes(plainHTTPClient, hostname, oldToken); 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.

If the token has expired then what happens here? Are we able to get the scopes? If not, will this just drop the scopes? Maybe we need to use ActiveTokenWithRefresh?

@williammartin
williammartin removed the request for review from niik October 6, 2026 10:44

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

in practice most users who let gh manage git already have gh configured as their helper, so this path is uncommon to begin with.

Based on what data?

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

Credential replacement can retain stale refresh tokens, and interactive short-lived login can silently bypass the requested OAuth flow.

Review effort: Balanced
Findings: 3 Medium severity · 2 Low severity

Open (5)
What changed in this PR

Adds user-facing short-lived OAuth credential support across login, refresh, storage, and Git authentication.

Changes:

  • Adds --short-lived to authentication commands.
  • Stores and refreshes renewable credentials.
  • Prevents Git helpers from caching stale short-lived tokens.
File Description
pkg/​cmd/​auth/​shared/​login_flow.go Handles refreshable login results.
pkg/​cmd/​auth/​shared/​login_flow_test.go Tests short-lived login outcomes.
pkg/​cmd/​auth/​shared/​gitcredentials/​updater.go Extracts credential rejection.
pkg/​cmd/​auth/​shared/​gitcredentials/​updater_test.go Tests rejection behavior.
pkg/​cmd/​auth/​shared/​git_credential.go Protects refreshable credentials from external helpers.
pkg/​cmd/​auth/​shared/​git_credential_test.go Tests external-helper policy.
pkg/​cmd/​auth/​refresh/​refresh.go Adds short-lived credential refresh support.
pkg/​cmd/​auth/​refresh/​refresh_test.go Tests refreshable credential storage.
pkg/​cmd/​auth/​login/​login.go Adds the --short-lived login flag.
pkg/​cmd/​auth/​login/​login_test.go Tests incompatible login flags.
pkg/​cmd/​auth/​gitcredential/​helper.go Refreshes and marks Git credentials ephemeral.
pkg/​cmd/​auth/​gitcredential/​helper_test.go Tests ephemeral and expired credentials.
internal/​authflow/​refresh.go Maps OAuth responses to credentials.
internal/​authflow/​flow.go Returns refreshable authentication results.

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

return err
}
} else {
if _, err := authCfg.Login(hostname, result.Username, result.Token, "", !opts.InsecureStorage); err != nil {
if authFlow == nil {
authFlow = authflow.AuthFlow
}
result, err := authFlow(opts.PlainHTTPClient, hostname, opts.IO, "", append(opts.Scopes, additionalScopes...), opts.Interactive, opts.Browser, opts.CopyToClipboard, opts.ShortLived)
if refreshableCredential != nil {
insecureStorageUsed, err = cfg.LoginRefreshable(hostname, username, *refreshableCredential, gitProtocol, opts.SecureStorage)
} else {
insecureStorageUsed, err = cfg.Login(hostname, username, authToken, gitProtocol, opts.SecureStorage)
Comment thread internal/authflow/flow.go
ClientSecret: oauthClientSecret,
CallbackURI: getCallbackURI(oauthHost),
Scopes: scopes,
RequestRefreshToken: requestRefreshToken,
Comment on lines +102 to +105
return fmt.Sprintf(
"The configured git credential helper for %[1]s cannot refresh short-lived tokens, so git operations will fail once the token expires. Run 'gh auth setup-git --hostname %[1]s' to let gh manage git authentication.",
hostname,
), nil

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

Options considered for the "refreshable token + non-gh helper" case:

Reject the existing credential and warn, storing nothing, with a hint to run gh auth setup-git --hostname HOST. (Chosen.)
Store the expiring token in the external helper anyway, with a warning that it will expire and the helper cannot refresh it.
Explain the problem, prompt the user, and offer to configure gh as the helper for the host; if yes proceed with that, otherwise reject and warn.
On every token refresh, write the new access token back into the non-gh helper to keep it fresh.

3 seems most reasonable here, and it should be the first thing that is shown. Alternatively, we could let them proceed and inform their credential helper with the warning that it won't handle expiry. Up to them.

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

As I review all this, I get the sense that --expiring is a better flag than --short-lived. The former is a statement of fact about what will happen, and the latter is subjective. In most cases it also seems like expiring is how this feature is referred to https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#expiring-access-tokens

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