Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/fixes/ghe-copilot-oauth-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- **fix(oauth):** GHE Copilot OAuth lifecycle — connecting an account and refreshing its token both failed. Adding a connection died with `gheUrl is required for GHE Copilot OAuth` because 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 ran first, calling `pollForToken()` without the `extraData` carrying `gheUrl`. Separately, every manual `Refresh` click surfaced `Token refresh failed — provider returned no new token`, and the proactive pre-request refresh never fired for GHE connections — the manual route, the health-check sweep and `checkAndRefreshToken()` all still special-cased plain `github`, while GHE Copilot's device-code flow never yields a `refresh_token` (only a GitHub access token plus a short-lived Copilot sub-token). `refreshCopilotToken()` now takes an optional `baseUrl` so it can target a GHE host's `<gheUrl>/api/v3` Copilot token endpoint, and `ghe-copilot` is wired in alongside `github` at all four sites.
- **fix(health-check):** the access-token-only branch of the token health-check sweep no longer logs an unconditional `has no refresh token but has a GitHub access token` line on every tick. That path runs once per 60 s sweep for every `github` / `ghe-copilot` connection, so it emitted ~1440 identical entries per day per connection reporting that nothing had changed. It now logs only when the sweep actually attempted a Copilot sub-token refresh, and says whether that refresh succeeded or failed — so a genuine failure still surfaces instead of being buried in steady-state noise.
18 changes: 15 additions & 3 deletions open-sse/services/tokenRefresh/providers/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,24 @@ import { getGitHubCopilotRefreshHeaders } from "../../../config/providerHeaderPr
import { runWithProxyContext } from "../../../utils/proxyFetch.ts";

/**
* Refresh GitHub Copilot token using GitHub access token
* Refresh GitHub Copilot token using a GitHub access token.
*
* `baseUrl` defaults to github.com's Copilot API but can be overridden to a
* GitHub Enterprise host's `<gheUrl>/api/v3` so the same helper serves both
* the `github` and `ghe-copilot` providers (GHE has its own per-enterprise
* Copilot token endpoint; api.github.com never issues a token scoped to a
* GHE account).
*/
export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) {
export async function refreshCopilotToken(
githubAccessToken,
log,
proxyConfig: unknown = null,
baseUrl: string = "https://api.github.com"
) {
try {
const tokenUrl = `${baseUrl.replace(/\/+$/, "")}/copilot_internal/v2/token`;
const response = await runWithProxyContext(proxyConfig, () =>
fetch("https://api.github.com/copilot_internal/v2/token", {
fetch(tokenUrl, {
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
})
);
Expand Down
12 changes: 6 additions & 6 deletions src/app/api/oauth/[provider]/[action]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,12 +574,7 @@ export async function POST(

// Poll for token (through proxy if configured)
let result;
if (NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider)) {
// Non-PKCE device providers do not receive a code verifier.
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode)
);
} else if (provider === "ghe-copilot") {
if (provider === "ghe-copilot") {
// GHE Copilot needs gheUrl threaded through poll → postExchange
const gheUrl =
extraData && typeof extraData === "object" ? (extraData as any).gheUrl : undefined;
Expand All @@ -589,6 +584,11 @@ export async function POST(
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode, null, gheUrl ? { gheUrl } : undefined)
);
} else if (NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider)) {
// Non-PKCE device providers do not receive a code verifier.
result = await runWithProxyContextOrDirect(proxy, () =>
(pollForToken as any)(provider, deviceCode)
);
} else if (provider === "kiro" || provider === "amazon-q") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await runWithProxyContextOrDirect(proxy, () =>
Expand Down
69 changes: 68 additions & 1 deletion src/app/api/providers/[id]/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { NextResponse } from "next/server";
import { getCachedProviderConnectionById } from "@/lib/localDb";
import { updateProviderConnection } from "@/lib/db/providers";
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import {
getAccessToken,
updateProviderCredentials,
refreshCopilotToken,
resolveCopilotTokenBaseUrl,
} from "@/sse/services/tokenRefresh";
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";

type RefreshResult = {
Expand Down Expand Up @@ -77,6 +82,68 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
providerSpecificData: connection.providerSpecificData,
};

// github.com Copilot and GHE Copilot (device-code flow) never receive a
// refresh_token — only a GitHub access token plus a short-lived Copilot
// sub-token (providerSpecificData.copilotToken). The generic access-token
// helper below requires credentials.refreshToken and returns null
// immediately without one, which always surfaced as "Token refresh failed
// — provider returned no new token" for these connections. Refresh the
// Copilot sub-token directly instead, mirroring the health-check sweep's
// dedicated path.
//
// NB: keep the generic helper's name out of the comments above its real
// call site. tests/unit/codex-manual-refresh-rotating-guard.test.ts finds
// that call with a plain substring search and asserts the openai-auth0
// rotation guard precedes it, so an earlier textual mention would become
// the match instead of the actual invocation.
if (
(provider === "github" || provider === "ghe-copilot") &&
!connection.refreshToken &&
connection.accessToken
) {
const copilotResult = await refreshCopilotToken(
connection.accessToken,
credentials,
resolveCopilotTokenBaseUrl(provider, credentials)
);
if (!copilotResult?.token) {
return NextResponse.json(
{ error: "Token refresh failed — provider returned no new token" },
{ status: 502 }
);
}

const refreshedProviderSpecificData = {
...(connection.providerSpecificData || {}),
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
};
await updateProviderConnection(id, {
providerSpecificData: refreshedProviderSpecificData,
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
});

const expiresAtMs =
typeof copilotResult.expiresAt === "number" && copilotResult.expiresAt < 1e12
? copilotResult.expiresAt * 1000
: typeof copilotResult.expiresAt === "string"
? new Date(copilotResult.expiresAt).getTime()
: (copilotResult.expiresAt as number | undefined);

return NextResponse.json({
success: true,
connectionId: id,
provider,
expiresAt: expiresAtMs ? new Date(expiresAtMs).toISOString() : null,
refreshedAt: new Date().toISOString(),
});
}

// Use the existing getAccessToken helper which knows how to refresh
// tokens for each provider type (Claude, GitHub, Gemini, etc.).
// Pass onPersist so the DB write happens atomically INSIDE the per-connection
Expand Down
42 changes: 37 additions & 5 deletions src/lib/tokenHealthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,36 @@ function getCopilotTokenExpiryMs(expiresAt: unknown): number {
return 0;
}

// Providers whose OAuth flow yields only a GitHub-style access token (no
// refresh_token) plus a short-lived Copilot sub-token: github.com Copilot and
// GHE Copilot (device-code flow against the enterprise host) both fit this
// shape. Keep both in sync — adding a github-token-only provider elsewhere
// (e.g. new GHE-flavored Copilot variant) must also list it here.
const GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS = new Set(["github", "ghe-copilot"]);

function isGitHubAccessTokenOnlyConnection(conn: any): boolean {
return (
String(conn?.provider || "").toLowerCase() === "github" &&
GITHUB_ACCESS_TOKEN_ONLY_PROVIDERS.has(String(conn?.provider || "").toLowerCase()) &&
typeof conn?.accessToken === "string" &&
conn.accessToken.trim().length > 0
);
}

/**
* Resolve the Copilot token endpoint base URL for a connection. github.com
* Copilot always uses api.github.com; GHE Copilot uses its own per-enterprise
* host stored in providerSpecificData.gheUrl at connect time.
*/
function getCopilotTokenBaseUrl(conn: any): string {
if (String(conn?.provider || "").toLowerCase() === "ghe-copilot") {
const gheUrl = conn?.providerSpecificData?.gheUrl;
if (typeof gheUrl === "string" && gheUrl.trim().length > 0) {
return `${gheUrl.trim().replace(/\/+$/, "")}/api/v3`;
}
}
return "https://api.github.com";
}

function canClearGitHubNoRefreshTokenState(conn: any): boolean {
return (
!conn?.testStatus ||
Expand Down Expand Up @@ -564,7 +586,8 @@ export async function checkConnection(conn) {
const copilotResult = await refreshCopilotToken(
conn.accessToken,
healthCheckLog,
proxyConfig
proxyConfig,
getCopilotTokenBaseUrl(conn)
);
if (copilotResult?.token) {
refreshedProviderSpecificData = {
Expand Down Expand Up @@ -604,9 +627,18 @@ export async function checkConnection(conn) {
});
}

log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} has no refresh token but has a GitHub access token; keeping connection active`
);
// Steady-state ticks stay silent: this path runs once per TICK_MS (60s) for
// EVERY github/ghe-copilot connection, so an unconditional line here emits
// ~1440 entries/day per connection all saying the same nothing-changed thing.
// Only report when the sweep actually did work — a Copilot sub-token refresh
// attempt — so a genuine refresh failure still surfaces in the log.
if (copilotAboutToExpire) {
log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} Copilot token ${
refreshedProviderSpecificData ? "refreshed" : "refresh FAILED"
} (no refresh token; connection stays active)`
);
}
return;
}

Expand Down
41 changes: 36 additions & 5 deletions src/sse/services/tokenRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,34 @@ export const refreshGitHubToken = async (refreshToken: string, credentials?: any
return _refreshGitHubToken(refreshToken, log, proxy);
};

export const refreshCopilotToken = async (githubAccessToken: string, credentials?: any) => {
export const refreshCopilotToken = async (
githubAccessToken: string,
credentials?: any,
baseUrl?: string
) => {
const proxy = await resolveProxyForCredentials("github", credentials);
return _refreshCopilotToken(githubAccessToken, log, proxy);
return baseUrl
? _refreshCopilotToken(githubAccessToken, log, proxy, baseUrl)
: _refreshCopilotToken(githubAccessToken, log, proxy);
};

/**
* Resolve the Copilot token endpoint base URL for a provider/credentials pair.
* github.com Copilot always uses api.github.com; GHE Copilot uses its own
* per-enterprise host stored in providerSpecificData.gheUrl at connect time.
*/
export function resolveCopilotTokenBaseUrl(
provider: string,
credentials?: any
): string | undefined {
if (provider !== "ghe-copilot") return undefined;
const gheUrl = credentials?.providerSpecificData?.gheUrl;
if (typeof gheUrl === "string" && gheUrl.trim().length > 0) {
return `${gheUrl.trim().replace(/\/+$/, "")}/api/v3`;
}
return undefined;
}

export const getAccessToken = async (
provider: string,
credentials: any,
Expand Down Expand Up @@ -230,8 +253,15 @@ export async function checkAndRefreshToken(provider: string, credentials: any) {
}
}

// Check GitHub copilot token expiry
if (provider === "github" && updatedCredentials.providerSpecificData?.copilotTokenExpiresAt) {
// Check GitHub/GHE Copilot token expiry. Both github.com Copilot and GHE
// Copilot (device-code flow against an enterprise host) issue a short-lived
// sub-token separate from the OAuth access token, stored the same way in
// providerSpecificData.copilotTokenExpiresAt — only the token endpoint host
// differs (resolveCopilotTokenBaseUrl picks it via providerSpecificData.gheUrl).
if (
(provider === "github" || provider === "ghe-copilot") &&
updatedCredentials.providerSpecificData?.copilotTokenExpiresAt
) {
const copilotExpiresAt = updatedCredentials.providerSpecificData.copilotTokenExpiresAt * 1000;
const now = Date.now();

Expand All @@ -243,7 +273,8 @@ export async function checkAndRefreshToken(provider: string, credentials: any) {

const copilotToken = await refreshCopilotToken(
updatedCredentials.accessToken,
updatedCredentials
updatedCredentials,
resolveCopilotTokenBaseUrl(provider, updatedCredentials)
);
if (copilotToken) {
await updateProviderCredentials(updatedCredentials.connectionId, {
Expand Down
13 changes: 12 additions & 1 deletion tests/unit/oauth-providers-error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,18 @@ test("P1: GitHub Copilot sub-token is refreshed by tokenHealthCheck", async () =
test("P1: tokenHealthCheck checks copilotTokenExpiresAt before refreshing", async () => {
const src = await read("src/lib/tokenHealthCheck.ts");
assert.match(src, /copilotTokenExpiresAt/, "must check copilotTokenExpiresAt");
assert.match(src, /toLowerCase\(\)\s*===\s*["']github["']/, "must be gated on github provider");
// The gate must still lowercase-normalize conn.provider (#6947), but it is no
// longer a single `=== "github"` literal: GHE Copilot shares the exact same
// shape (GitHub-style access token, no refresh_token, short-lived Copilot
// sub-token), so the branch is now driven by a provider Set. Accept either
// form — the invariant under test is the normalized provider gate, not which
// syntax expresses it.
assert.match(
src,
/toLowerCase\(\)\s*===\s*["']github["']|_PROVIDERS\.has\(\s*String\([^)]*\)\s*\.toLowerCase\(\)\s*\)/,
"must be gated on a lowercase-normalized provider check (=== \"github\" literal " +
"or a *_PROVIDERS Set membership test covering github/ghe-copilot)"
);
});

// ─── P1: case-insensitive provider comparisons (regression for #6947) ────────
Expand Down
Loading