Skip to content

auth: hand Git fresh credentials [5/10] - #22940

Merged
sergiou87 merged 6 commits into
developmentfrom
short-lived-tokens-5-git
Oct 7, 2026
Merged

sergiou87 merged 6 commits into
developmentfrom
short-lived-tokens-5-git

Conversation

@sergiou87

@sergiou87 sergiou87 commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Description

Git and Git LFS get GitHub credentials from Desktop's credential helper trampoline. The helper now asks AccountsStore for a fresh token before replying, so Git never receives an expired short-lived token. Plain tokens come back unchanged, and Git still gets the usual username/password form.

Stack

Merge in order, starting with 1. Each PR builds, passes tests, and keeps production behavior on its own.

  1. auth: add OAuth refresh-token protocol primitives [1/10] #22917 - OAuth protocol primitives
  2. auth: handle rejected API tokens in AccountsStore [2/10] #22937 - Token invalidation in AccountsStore
  3. auth: add versioned secure-storage format for credentials [3/10] #22938 - Versioned credential storage
  4. auth: coordinate OAuth token renewal in AccountsStore [4/10] #22939 - Coordinated token renewal
  5. auth: hand Git fresh credentials [5/10] #22940 - Git credentials (this PR)
  6. auth: resolve private image tokens through the renderer [6/10] #22941 - Private images
  7. auth: let Copilot sessions renew GitHub tokens [7/10] #22942 - Copilot SDK sessions
  8. auth: request refreshable OAuth tokens at sign-in [8/10] #22943 - Sign-in with refreshable tokens (behind flag)
  9. docs: describe refresh-token rollout and recovery [9/10] #22919 - Rollout and recovery documentation
  10. auth: log automatic sign-outs [10/10] #22959 - Log automatic sign-outs

Validation

  • tsc and lint pass.
  • Full suite: 1,975 pass, one skipped, same four Git failures.

Release notes

Notes: no-notes

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 security-sensitive debug-log redaction lacks regression coverage.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
What changed in this PR

Routes Git and Git LFS authentication through fresh AccountsStore tokens while protecting refreshable credentials.

Changes:

  • Renews credentials before returning them to Git.
  • Uses ephemeral Basic credentials when supported, with legacy fallback.
  • Redacts additional secrets from debug logs and adds credential tests.
File Description
app/​src/​lib/​trampoline/​trampoline-credential-helper.ts Adds fresh, ephemeral credentials and log redaction.
app/​test/​unit/​refresh-token-consumers-test.ts Tests renewal and credential formats.

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

Comment thread app/src/lib/trampoline/trampoline-credential-helper.ts Outdated

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

🟢 Approval recommended

The implementation matches the described behavior and includes focused coverage for compatibility and secret redaction.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@sergiou87 sergiou87 changed the title auth: hand Git fresh, ephemeral credentials [5/9] auth: hand Git fresh credentials [5/9] Sep 28, 2026
@sergiou87
sergiou87 requested a balanced review from Copilot September 28, 2026 15:07

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

🟢 Approval recommended

The focused implementation correctly delegates renewal to AccountsStore and is covered by an integration-level regression test.

Review effort: Balanced
Findings: None

@sergiou87
sergiou87 marked this pull request as ready for review September 28, 2026 15:13
@sergiou87
sergiou87 requested a review from a team as a code owner September 28, 2026 15:13
@sergiou87
sergiou87 requested a review from babakks September 28, 2026 15:13
@sergiou87
sergiou87 force-pushed the short-lived-tokens-5-git branch from 55d5555 to 9d5009a Compare October 2, 2026 11:28
@sergiou87
sergiou87 force-pushed the short-lived-tokens-5-git branch from 9d5009a to 1660bf1 Compare October 2, 2026 11:42
@sergiou87 sergiou87 changed the title auth: hand Git fresh credentials [5/9] auth: hand Git fresh credentials [5/10] Oct 2, 2026
@sergiou87
sergiou87 force-pushed the short-lived-tokens-5-git branch from 1660bf1 to e377ca7 Compare October 2, 2026 12:18

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

The final delta is nicely minimal and correctly asks AccountsStore for a fresh credential before returning it to Git. I see two integration issues that I think need resolution before this layer lands.

First, Git caches credentials returned by a helper and may reuse them for later HTTP requests without invoking the helper again. Refreshing for a second Git/LFS operation immediately invalidates the old access token. A command that received a token just outside the ten-minute margin can therefore be broken when a concurrent operation or pre-push LFS work crosses the margin and rotates it. The original command’s next authenticated request uses its cached token, receives a 401, and Git does not necessarily reacquire credentials; a subprocess reproduction exited 128 in this situation. This does not require an eight-hour operation, only overlap near the renewal boundary. The direct one-shot helper test cannot expose it.

I think Git credentials need coordination with active consumers rather than independent per-helper freshness checks—likely a lease/pin associated with the trampoline environment or Git process lifecycle. Renew before issuing a near-expiry credential, then avoid rotating that credential while active Git consumers still hold it. Please add a subprocess-level regression covering discovery with one token, rotation by another consumer before the next authenticated request, and the resulting push/LFS behavior.

Second, when renewal retires the account or otherwise requires sign-in, the exception reaches the trampoline handler’s outer catch. That returns no credential but bypasses the existing no-account path: no interactive GitHub sign-in prompt, no setHasRejectedCredentialsForEndpoint marker, and no translation to the recognized HTTPSAuthenticationFailed error for background tasks. The user instead gets a cryptic terminal-prompts-disabled Git error. Please handle the retired-account case in or below getGitHubCredential so it re-enters the existing sign-in/background-authentication behavior.

It would also be valuable to cover plain tokens, sufficiently valid refreshable tokens, renewal rejection, and transient renewal failure in the integration tests.

Base automatically changed from short-lived-tokens-4-renewal to development October 5, 2026 11:35
Git and Git LFS get GitHub credentials from Desktop's credential
helper trampoline. With short-lived tokens, the token stored on the
Account can have expired, and Git has no way to renew it. Have the
trampoline ask AccountsStore for a fresh token first.

For every GitHub account the helper now calls
`getAccountWithFreshToken`, which renews the access token if it
expires within ten minutes. Plain (non-refreshable) tokens are
returned unchanged, so Git behaves as before for existing accounts.

For refreshable accounts, if Git says it supports the `authtype`
capability (`capability[]=authtype`), the helper replies with a
pre-encoded `Authorization: Basic` credential and `ephemeral=1`
instead of a username and password. Git then uses the token for this
request only and doesn't pass it to other credential helpers to store,
so an expiring token never ends up saved in the system keychain or a
credential file. Git versions and Git LFS without the capability still
get the username and password form.

The helper's debug logging now redacts `credential=` and
`oauth_refresh_token=` lines as well as passwords.

When no GitHub account matches the endpoint, the helper returns
undefined explicitly instead of passing an undefined account to
`credWithAccount`, which returned undefined anyway.

Tests cover renewal before replying in both the `authtype` and the
username/password forms, without leaking the old access token or any
refresh token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
sergiou87 and others added 3 commits October 5, 2026 13:35
In development builds the credential helper trampoline logs the raw
stdin Git sends it. This branch added a second `replaceAll` that
replaces `credential=` (the base64 Basic credential sent with the
`authtype` capability) and `oauth_refresh_token=` lines with
`=[redacted]`, next to the existing `password=***` masking. Nothing
covered it, so dropping or breaking that line would silently start
writing live credentials to the development log.

The new test "redacts secrets from development debug logs" in
`refresh-token-consumers-test.ts`:

- Sets `__DEV__` to true for the duration of the test (tests run with
  it false, see `app/test/globals.mts`) and restores it in `t.after`.
- Captures `log.debug` with `t.mock.method`.
- Sends an `erase` command for `github.com` whose stdin contains
  `password=`, `credential=` and `oauth_refresh_token=` lines, each
  with a distinct `secret-` value. `erase` for a GitHub host returns
  before touching any credential store, so the call has no side
  effects.
- Asserts the log still contains the non-secret `host=github.com`
  line (so the test would notice if logging stopped altogether), shows
  `credential=[redacted]` and `oauth_refresh_token=[redacted]`, and
  contains none of the three secret values.

Verified that the test fails when the new redaction line is removed
and passes with it. No source changes.

Addresses review feedback on #22940.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Simplify the Git credential helper change to the one thing short-lived
tokens need: ask AccountsStore for a fresh token before replying.
`getGitHubCredential` now returns
`credWithAccount(cred, await store.getAccountWithFreshToken(account))`
for a matching GitHub account and is otherwise unchanged from
`development`.

Removed:

- The `capability[]=authtype` branch that, for refreshable accounts,
  replied with `authtype=Basic`, a base64 `credential=` and
  `ephemeral=1` instead of a username and password. It produced the
  same `Authorization: Basic` header Git builds from a username and
  password. `ephemeral=1` only stops Git from handing the credential to
  other helpers' `store`, but Desktop already runs Git with
  `credential.helper=` followed by `credential.helper=desktop`, which
  clears every system, global and repository helper, and Desktop's own
  `store`/`erase` return early for GitHub endpoints. So nothing
  persisted the token before, and the extra response format was
  defense against a configuration Desktop does not use.
- The extra debug-log redaction of `credential=` and
  `oauth_refresh_token=` lines, which only existed for that format.
  Password masking is unchanged.
- The test "redacts secrets from development debug logs", which
  covered that redaction.
- The explicit `return undefined` for the no-account case; the
  original `return credWithAccount(cred, account)` already returns
  undefined when there is no account.

The Git test is now a single case, "refreshes before returning
credentials": Git asks for github.com credentials with an expiring
refreshable account, and the helper renews once and replies with the
account login and the renewed token as username and password, without
the old access token or any refresh token. It fails against the
previous helper, which returned the stored token without renewing.

Git and Git LFS both keep receiving username and password, exactly as
on `development`; only the token is now guaranteed fresh.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Review feedback on #22940 found two problems with handing refreshable
credentials to Git.

1. Renewal broke Git processes that already held the previous token.

Git asks the credential helper once per process and reuses the credential
for every later HTTP request that process makes; Git LFS, which Git spawns,
does the same. GitHub revokes the previous access token as soon as a refresh
token is used ("Once you use a refresh token, that refresh token and the old
user access token will no longer work"). So when any other consumer renewed
the token while a Git process was running (an API request once the token
entered the ten-minute renewal window, a background fetch, another Git
operation, Copilot), that process's next authenticated request got a 401 and
Git failed with "RPC failed; HTTP 401". This did not need a long operation,
only overlap with the moment of renewal.

Tokens can now be leased by consumers that keep using them:

- CredentialSessions.leaseToken(account, holder) returns an access token
  and a release function. Leases are recorded per access token and holder
  on the session.
- Whenever anyone needs a token renewed (API clients, Git, Copilot,
  images; through getFreshToken, resolveToken or leaseToken) and that token
  is leased, they wait until every holder releases it, then renew it as
  usual. The wait also ends when the session is retired (the caller then
  gets AccountRequiresSignInError) or when the token comes within one minute
  of expiring (leasedRenewalMargin), at which point it stops working for
  its holders anyway and is renewed regardless.
- A holder that already leases the token gets the same token again instead
  of waiting or renewing. Git LFS asks the credential helper under its parent
  Git process's trampoline token; waiting would deadlock on the parent, and
  renewing would revoke the token the parent is still using.
- leaseToken only takes the lease when no renewal is in flight and the token
  is still the session's current one. If a renewal started while the token
  was being handed out, it waits for it and leases the new token instead, so
  it never hands out a token that is about to be revoked. Conversely, the
  check for leases and the start of a renewal happen in the same synchronous
  step, so a lease cannot be taken on a token whose renewal has begun; if a
  new lease is taken while a caller is waking up, the caller waits again.
- AccountsStore.leaseAccountToken wraps it with the same account checks as
  getAccountWithFreshToken.
- The credential helper leases every GitHub credential it returns, using
  the trampoline token as the holder, and registers the release with
  holdCredentialLease. withTrampolineEnv already brackets the whole Git
  process (and the LFS processes it spawns), so it now releases those leases
  in its finally block, next to the rest of the per-token cleanup. A lease
  registered for a trampoline token that is no longer valid is released
  immediately.

The trade-off is that during the last ten minutes of a token's lifetime,
work that needs it renewed can wait behind a long-running Git operation, at
most until a minute before the token expires.

2. A signed-out account bypassed the existing sign-in path.

When renewal was rejected, the account was signed out and the helper's
getAccountWithFreshToken call threw. The error reached the handler's outer
catch, which returned no credential without prompting for sign-in, without
recording rejected credentials for the endpoint, and therefore without
translating Git's "terminal prompts disabled" failure into the usual
authentication error for background tasks. getGitHubCredential now treats
AccountRequiresSignInError as "no account for this host", so getCredential
continues down the same path as when no account exists: it prompts to sign
in for foreground operations, or returns nothing for background tasks and
lets the handler mark the endpoint as rejected. leaseToken reports rejected
renewal as AccountRequiresSignInError once the session has been retired.

If renewal fails transiently (network or server errors) and the account is
still signed in, leaseToken now falls back to the current access token while
it has not expired, so a short outage does not fail Git operations that
could still authenticate. Other consumers keep their existing behavior.

Tests:

- app/test/unit/git/refreshable-credentials-test.ts runs a real git push
  through Desktop's credential helper binary against a smart HTTP server
  backed by git http-backend, which only accepts the current token and
  revokes old ones on renewal, as GitHub does. Between Git's first
  authenticated request and the next one, the token moves inside the renewal
  window and an API consumer asks for a fresh token. The push must succeed,
  and the consumer must receive the renewed token only after Git exits.
  Without the wait the push fails with "RPC failed; HTTP 401". The server
  runs as an HTTP proxy so the remote can use a host without a port, which
  is how Desktop matches accounts to remotes. The remote is read with
  --git-dir so the test also works with safe.bareRepository=explicit. Git
  LFS is not covered.
- credential-sessions-test: renewal waits for every holder and releasing
  twice is harmless; a holder gets the token it already holds without
  renewal; a leased token is renewed when about to expire, including by a
  waiting caller once that point is reached; sign-out ends the wait; a
  renewal that starts while a token is being leased results in leasing the
  renewed token; transient failure falls back to an unexpired token but not
  an expired one; rejected renewal requires sign-in.
- refresh-token-consumers-test: plain tokens are returned unchanged,
  sufficiently valid refreshable tokens are returned without renewing,
  transient renewal failure returns the current token and keeps the account,
  and rejected renewal signs out, prompts for GitHub sign-in and marks the
  endpoint as rejected.

Each new test fails without the corresponding change; without the holder
exception, the test for a holder asking again hangs waiting for itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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

A transient renewal failure can expose a refreshable token whose expiration is unknown and which may already be expired.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread app/src/lib/credential-sessions.ts
sergiou87 and others added 2 commits October 5, 2026 14:31
The refreshable credential push test failed on Windows with "Cannot access
URL ... return code 22" followed by "fatal: git-http-push failed". This was
a test-server failure, not a credential lease failure.

Desktop's Windows Git distribution comes from MinGit. The exact bundled
Windows x64 archive in dugite 3.2.3 contains receive-pack but does not ship
git-http-backend.exe. The test attempted to launch http-backend anyway,
ignored its nonzero exit status and stderr, and translated its empty stdout
into an HTTP 200 with no smart-HTTP advertisement. Git then fell back to
the unsupported dumb-HTTP push protocol and failed.

Serve the two requests needed by this regression directly with the bundled
Git receive-pack plumbing instead of relying on an unavailable CGI program:

- Discovery uses receive-pack --stateless-rpc --advertise-refs and wraps its
  output with the smart-HTTP service announcement and advertisement MIME
  type.
- The push POST pipes its request into receive-pack --stateless-rpc and
  returns the resulting packet stream with the RPC result MIME type.
- Both invocations use an explicit operand boundary and the absolute bare
  repository path. Unknown routes return HTTP 404.
- Capture stderr, check the subprocess exit status, and return HTTP 500 on
  backend failure instead of a success-shaped empty response. A launch
  error closes the HTTP response before propagating the original error.

The test still runs a real Git push through Desktop's credential helper,
authenticates each HTTP request against the current token, verifies the
remote ref, and verifies that the other credential consumer waits until
Git exits before renewing. No production code, lease semantics, dependencies,
platform skips, or workflow configuration change.

Validation:

- Confirmed the missing backend in the bundled Windows x64 release archive.
- Reproduced the exact Windows failure on macOS by pointing the old backend
  invocation's GIT_EXEC_PATH at a directory without external Git helpers.
- The replacement server passes with that same restriction, using built-in
  Git commands only. The restriction was temporary and is not committed.
- Temporarily disabling the lease wait still makes the real push fail with
  "RPC failed; HTTP 401", preserving the original regression coverage.
- yarn test app/test/unit/git/refreshable-credentials-test.ts
  app/test/unit/credential-sessions-test.ts
  app/test/unit/refresh-token-consumers-test.ts: 21 passed.
- yarn -s lint:fix: passed.
- npx tsc -p . --noEmit --skipLibCheck: passed. The unfiltered check reports
  four existing WeakMap declaration conflicts in dependency type files.

Windows execution requires the next CI run; it cannot be run on this
macOS host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a comment to parseOAuthToken explaining why a refresh token can be
accepted without expiry metadata. GitHub documents expires_in and
refresh_token_expires_in for refreshable OAuth credentials, so this parser
behavior is defensive tolerance for an unexpected response, not recognition
of a documented GitHub response shape.

This clarifies the distinction raised in review 5413998626 on #22940:
accepting optional metadata in our parser does not establish that GitHub
legitimately omits it from refreshable-token responses. The existing comment
still explains that absent lifetimes remain unknown rather than being
invented, while zero means the token is already expired.

No parsing rules, credential fallback behavior, public types, or tests change.

Validation:
- yarn -s lint:fix: passed.
- git diff --check: passed.
- Unit tests not rerun because the change is documentation-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

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

🔵 Needs a closer look

Credential renewal and concurrent lease coordination are security-sensitive and warrant final human review.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@sergiou87

Copy link
Copy Markdown
Member Author

Thanks @niik! Both were hopefully addressed in f16a017.

1. Renewal revoking a token a running Git process still uses. Tokens can now be leased. The credential helper leases every GitHub credential it hands out, keyed by the trampoline token. The lease is released in withTrampolineEnv's finally, so it covers the whole Git process, including any Git LFS processes it spawns under the same trampoline token. While a token is leased, anyone who needs it renewed (API clients, Copilot, image requests, another Git process) waits until all holders release it. The wait also ends if the account signs out, or once the token is within a minute of expiring, since at that point it stops working for its holders anyway. A holder that already leases the token, like LFS under its parent Git process, gets the same token back instead of waiting or renewing. Leases are only taken when no renewal is in flight, so Git is never handed a token that's about to be revoked. The trade-off is that during a token's last ten minutes, work that needs a renewal can wait behind a long Git operation.

2. AccountRequiresSignInError skipping the sign-in path. The helper now treats it like having no account for the host. Foreground operations prompt to sign in, and background ones mark the endpoint as rejected, so you get the usual authentication error. Also, if renewal fails transiently, Git keeps using the current token while it hasn't expired, instead of failing the operation.

Tests:

  • Helper level: plain token, valid refreshable token (no renewal), transient failure (current token, account kept), and rejected renewal (signed out, sign-in prompt, rejected marker).
  • credential-sessions-test: the lease and waiting behavior.
  • app/test/unit/git/refreshable-credentials-test.ts: a real git push through Desktop's credential helper binary, against a git http-backend server that revokes old tokens on renewal, the way GitHub does. Mid-push, an API consumer needs a renewed token. The push succeeds, and the consumer gets the renewed token only after Git exits. Without leases, the push fails with RPC failed; HTTP 401.

LFS isn't covered end to end, since standing up an LFS server in the tests felt too heavy. It runs under its parent Git process's trampoline token, so it shares that lease.


@sergiou87
sergiou87 requested a review from niik October 5, 2026 14:53
@sergiou87
sergiou87 merged commit 99f8b59 into development Oct 7, 2026
10 checks passed
@sergiou87
sergiou87 deleted the short-lived-tokens-5-git branch October 7, 2026 12:37
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