Skip to content

fix(oauth): GHE Copilot OAuth lifecycle — connect, manual refresh, proactive refresh - #8970

Merged
diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.50from
hppsc1215:fix/ghe-copilot-oauth-gheurl-poll
Aug 6, 2026
Merged

diegosouzapw merged 6 commits into
diegosouzapw:release/v3.8.50from
hppsc1215:fix/ghe-copilot-oauth-gheurl-poll

Conversation

@hppsc1215

@hppsc1215 hppsc1215 commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

GHE Copilot's OAuth lifecycle was broken end to end: an account could not be connected, and once connected its token could not be refreshed. Three related fixes, all in provider-specific branches that ghe-copilot was left out of when the provider was introduced.

1. Connecting an account failed with gheUrl is required for GHE Copilot OAuth.
The poll handler's ghe-copilot branch was unreachable dead code. The provider is listed in NO_PKCE_DEVICE_CODE_PROVIDERS, and that set-based check runs first — so pollForToken() was called without the extraData carrying gheUrl, and normalizeGheUrl(extraData?.gheUrl || config.gheUrl) threw because neither source was populated. Fixed by checking provider === "ghe-copilot" before falling through to the set check. The device-code handler in the same file already used the correct pattern (gate on the set, special-case ghe-copilot inside the block).

2. Every manual Refresh click returned Token refresh failed — provider returned no new token, and the proactive refresh never fired.
GHE Copilot's device-code flow never yields a refresh_token — only a GitHub access token plus a short-lived (~30 min) Copilot sub-token. The manual refresh route went through getAccessToken(), which returns null immediately when credentials.refreshToken is missing. The health-check sweep and checkAndRefreshToken() (which runs before every chat request) had the same bare provider === "github" gate.

refreshCopilotToken() now takes an optional baseUrl so it can target a GHE host's <gheUrl>/api/v3 Copilot token endpoint instead of api.github.com, which never issues a token scoped to a GHE account. ghe-copilot is wired in alongside github at all four sites, with resolveCopilotTokenBaseUrl() / getCopilotTokenBaseUrl() deriving the host from providerSpecificData.gheUrl.

3. The health-check sweep logged one noise line per tick.
The access-token-only branch logged has no refresh token but has a GitHub access token; keeping connection active unconditionally. That path runs once per TICK_MS (60 s) for every github / ghe-copilot connection — ~1440 identical entries per day per connection, all reporting that nothing changed. The line predates this branch (v3.8.43, b729a8f27), but ghe-copilot only started reaching it once the provider was added to GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS in fix 2, which is what made the volume visible. It is now gated on copilotAboutToExpire — the same flag that already drives the surrounding status/error writes — and states whether the refresh succeeded or failed, so a genuine failure stays visible instead of being buried.

Note: fix 3 also affects plain github connections, not just ghe-copilot — it is the same shared code path and the same per-tick volume.

Related Issues

Validation

Verified live against a real GHE Copilot connection on a Docker build of this branch, not only by reading the code:

  • Connect — account deleted and re-added through the device-code flow; the connection persists with gheUrl, copilotApiUrl and copilotToken all populated. No gheUrl is required error.
  • Manual refresh — the dashboard Refresh button completes without the provider returned no new token error.
  • Proactive refresh — on the same connection, copilotTokenExpiresAt advanced ~10 h 05 m over a 10-hour window (~20 refresh cycles) while test_status stayed active. The sub-token lives ~30 min, so without a working proactive path the connection would have gone dead after the first expiry.
  • Log gating — after redeploy the per-tick line is gone; a single Copilot token refreshed (no refresh token; connection stays active) line appeared at the moment the 5-minute expiry buffer was crossed and the sweep actually refreshed the token.
  • Unit tests — node --experimental-strip-types --test tests/unit/oauth-providers-error-handling.test.ts → 18 tests, 17 pass, 1 fail. The one failure is P0: gitlab-duo is registered in providerRegistry, which throws ERR_MODULE_NOT_FOUND: Cannot find package '@/lib' — the @/ path alias needs the tsx loader, which this checkout has no node_modules for. That test is green in this PR's CI run, and two sibling health-check test files fail locally with the same alias error on a pristine upstream/release/v3.8.50 worktree, so it is a local-runner artifact rather than a regression.
  • Red-green on the assertion fix — with upstream's original assertion against this branch's source, P1: tokenHealthCheck checks copilotTokenExpiresAt before refreshing fails; with the widened assertion it passes. The widened pattern was also confirmed to still match upstream's literal form, and to still reject both a Set lookup and a literal comparison that omit .toLowerCase() — so the fix(tokenHealthCheck): normalize provider case in rotating/copilot checks #6947 case-normalization guard is not silently retired.
  • tests/unit/codex-manual-refresh-rotating-guard.test.ts → 3/3 pass (this PR touches the file it asserts over).
  • node scripts/check/check-file-size.mjs — green (121 frozen files, cap 1000); no rebaseline needed.
  • node scripts/check/check-changelog-integrity.mjs — green.
  • npm run lint, check:complexity, check:cognitive-complexity, vitest and e2e suites — not run locally: they need node_modules, which this checkout does not have. Left to CI.

CI status on this branch

Tests Added Or Updated

tests/unit/oauth-providers-error-handling.test.ts — widened one existing assertion. It required the literal toLowerCase() === "github" in src/lib/tokenHealthCheck.ts; that gate is now a lowercase-normalized *_PROVIDERS Set lookup so github and ghe-copilot both take the branch. The assertion accepts either form, and the surrounding invariant is unchanged (see the red-green evidence above). The sibling #6947 tests over ROTATING_REFRESH_PROVIDERS and tokenHealthCheckCopilot.ts are untouched and stayed green.

No new test files. The remaining changes are provider-gating and call-site wiring whose behaviour depends on a live GHE enterprise host (per-enterprise Copilot token endpoint + device-code flow), so they were validated end to end against a real connection as listed above. Happy to add a unit test around resolveCopilotTokenBaseUrl() (pure string derivation, trivially testable) or a fetch-mocked case for the manual-refresh branch if a maintainer would prefer that here.

Coverage Notes

Touches src/ and open-sse/:

File Change
src/app/api/oauth/[provider]/[action]/route.ts branch order in the poll handler
open-sse/services/tokenRefresh/providers/copilot.ts optional baseUrl parameter
src/sse/services/tokenRefresh.ts wrapper threads baseUrl; new resolveCopilotTokenBaseUrl(); ghe-copilot in checkAndRefreshToken()
src/lib/tokenHealthCheck.ts GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS set, getCopilotTokenBaseUrl(), log gating

No coverage regression: no existing branch was removed or narrowed. github keeps its exact previous behaviour — resolveCopilotTokenBaseUrl() returns undefined for any provider other than ghe-copilot, and the wrapper then calls the 3-argument form, so the api.github.com default path is byte-identical to before.

Reviewer Notes

  • Ported onto v3.8.50, and two of the four anchors had drifted from where the original fork branch found them: refreshCopilotToken was extracted out of open-sse/services/tokenRefresh.ts into tokenRefresh/providers/copilot.ts, and src/lib/tokenHealthCheck.ts now has a single consolidated refreshCopilotToken() call site instead of two (the second provider === "github" check no longer exists). Worth knowing if this is cross-referenced against the older fork history.
  • No new feature flag, no migration, no schema change. resolveCopilotTokenBaseUrl() is additive and inert for every provider except ghe-copilot.
  • NO_PKCE_DEVICE_CODE_PROVIDERS still contains ghe-copilot and should — GHE genuinely does not use PKCE. The set was never wrong; only the evaluation order was.
  • Fix 3 changes log output that operators may be grepping for. The old string has no refresh token but has a GitHub access token no longer appears.
  • One comment in src/app/api/providers/[id]/refresh/route.ts deliberately avoids writing the generic access-token helper's name followed by (. tests/unit/codex-manual-refresh-rotating-guard.test.ts locates that call with a plain substring search and asserts the openai-auth0 rotation guard precedes it, so a textual mention above the call site silently becomes the match instead. An earlier revision of this PR did exactly that; the assertion still passed but was anchored on prose. The constraint is noted inline at the comment.

@hppsc1215
hppsc1215 requested a review from diegosouzapw as a code owner July 30, 2026 09:06
@hppsc1215
hppsc1215 force-pushed the fix/ghe-copilot-oauth-gheurl-poll branch from 18ea44c to ecfdae6 Compare August 6, 2026 09:36
Alex added 6 commits August 6, 2026 12:27
…eachable

The ghe-copilot branch in the poll handler (intended to thread gheUrl via
extraData into pollForToken -> postExchange -> mapTokens) was unreachable
dead code: ghe-copilot was listed in NO_PKCE_DEVICE_CODE_PROVIDERS, and
the set-based check ran first, calling pollForToken without extraData.

The caller (pollForToken) read normalizeGheUrl(extraData?.gheUrl || config.gheUrl)
and threw 'gheUrl is required for GHE Copilot OAuth' because neither source
was populated.

Swap the order: check provider === "ghe-copilot" first, then fall through to
the NO_PKCE set check for the remaining providers (github, kimi-coding, ...).
The device-code handler (lines 214-221) already uses the correct pattern:
it gates on the set but special-cases ghe-copilot inside the block.
…fresh

The reactive 401 refresh (GheCopilotExecutor) already handled GHE, but the
manual 'Refresh' button and the background health-check sweep run through a
separate generic OAuth-refresh system that only ever special-cased plain
'github'. Every manual refresh click surfaced 'Token refresh failed —
provider returned no new token'.

Root cause: GHE Copilot's device-code flow never yields a refresh_token —
only a GitHub access token plus a short-lived Copilot sub-token. The manual
refresh route went through getAccessToken(), which returns null immediately
when credentials.refreshToken is missing.

Four sites wired:
1. open-sse/services/tokenRefresh/providers/copilot.ts — refreshCopilotToken()
   gains an optional baseUrl (default api.github.com) so it can target a GHE
   host's <gheUrl>/api/v3 Copilot token endpoint.
2. src/sse/services/tokenRefresh.ts — wrapper threads baseUrl through; new
   resolveCopilotTokenBaseUrl() derives it from providerSpecificData.gheUrl.
   checkAndRefreshToken() (runs before EVERY chat request) now covers
   ghe-copilot alongside github.
3. src/lib/tokenHealthCheck.ts — GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS set
   replaces the bare provider === 'github' check; getCopilotTokenBaseUrl()
   picks the enterprise host for the health-check refresh.
4. src/app/api/providers/[id]/refresh/route.ts — dedicated branch for
   github/ghe-copilot connections without a refresh_token: refreshes the
   Copilot sub-token directly instead of going through getAccessToken().

Ported from the original fork branch onto v3.8.50; two of the four anchors
had drifted (refreshCopilotToken was extracted into its own module, and
tokenHealthCheck.ts now has a single consolidated call site instead of two).
…t did work

The access-token-only branch of checkConnection() logged an unconditional
'has no refresh token but has a GitHub access token; keeping connection
active' line on every sweep tick. That path runs once per TICK_MS (60s) for
every github / ghe-copilot connection, so it emitted ~1440 identical
entries per day per connection, all reporting that nothing had changed.

The line predates this branch (v3.8.43, b729a8f) but ghe-copilot only
started reaching it once the provider was added to
GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS earlier in this PR, which is what made
the volume noticeable.

Gate it on copilotAboutToExpire — the same flag that already drives the
surrounding status/error writes — so the line appears only when the sweep
actually attempted a Copilot sub-token refresh, and state whether that
attempt succeeded or failed. A genuine refresh failure therefore stays
visible instead of being buried in steady-state noise.

Applies to github connections as well, not just ghe-copilot: it is the
same shared code path and the same per-tick volume.
…-only gate

tests/unit/oauth-providers-error-handling.test.ts asserted that
src/lib/tokenHealthCheck.ts contains a literal `toLowerCase() === "github"`.
Earlier in this PR that gate became a Set membership test, because GHE Copilot
has the exact same shape as github.com Copilot (GitHub-style access token, no
refresh_token, short-lived Copilot sub-token) and both must take the branch:

  GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS.has(String(conn?.provider || "").toLowerCase())

The assertion is a structural grep over source text, so it failed on the new
syntax even though the behaviour it guards is intact and now strictly broader.

Widen it to accept either the literal comparison or a lowercase-normalized
*_PROVIDERS Set lookup. The invariant under test — the diegosouzapw#6947 case-normalization
guard, so a stored 'Github' still matches — is preserved: a Set lookup passing
bare `conn.provider` without .toLowerCase() still fails the assertion, as does
a literal comparison without normalization. Both negative cases were verified
against fixtures, and the widened pattern was confirmed to still match
upstream/release/v3.8.50's literal form, so this is not a one-way narrowing to
our branch's syntax.

The sibling diegosouzapw#6947 tests (ROTATING_REFRESH_PROVIDERS, and the sub-token guard in
tokenHealthCheckCopilot.ts) are untouched and stayed green.
…ve its call

tests/unit/codex-manual-refresh-rotating-guard.test.ts locates the generic
access-token helper call with a plain substring search and asserts the
openai-auth0 rotation guard precedes it. The explanatory comment added earlier
in this PR named that helper with a trailing paren, so the first substring hit
moved from the real call site to the comment — the ordering assertion still
passed, but it was anchored on prose instead of code.

Reword the comment to describe the helper without reproducing the searched
token, and note the constraint inline so the next editor does not reintroduce
it. No behaviour change.
@hppsc1215
hppsc1215 force-pushed the fix/ghe-copilot-oauth-gheurl-poll branch from a298a41 to ccf664d Compare August 6, 2026 10:29
@diegosouzapw
diegosouzapw merged commit 2745144 into diegosouzapw:release/v3.8.50 Aug 6, 2026
4 of 5 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…oactive refresh (diegosouzapw#8970)

Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
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.

2 participants