Skip to content

fix(opencode): exchange OAuth token for Copilot JWT to fix GitHub Enterprise auth#14189

Open
davidraehles wants to merge 1 commit intoanomalyco:devfrom
davidraehles:fix/github-enterprise-auth-3936
Open

fix(opencode): exchange OAuth token for Copilot JWT to fix GitHub Enterprise auth#14189
davidraehles wants to merge 1 commit intoanomalyco:devfrom
davidraehles:fix/github-enterprise-auth-3936

Conversation

@davidraehles
Copy link

Fixes #3936

What changed

The Copilot plugin was sending the raw OAuth access token as the Bearer token in API requests. This works for github.com but fails for GitHub Enterprise because Enterprise's Copilot API requires a short-lived JWT exchanged via /copilot_internal/v2/token.

This adds a exchangeForCopilotJWT() function that:

  • Exchanges the OAuth token for a Copilot JWT using the correct API host (api.github.com for public, api.{domain} for Enterprise)
  • Caches JWTs per domain with a 120-second refresh buffer before expiration
  • Falls back to the raw OAuth token if the exchange fails

Also adds Editor-Version and Copilot-Integration-Id headers that the Copilot API expects.

How I verified

  • Reviewed the GitHub device flow OAuth + Copilot token exchange flow against the RFC 8628 spec and existing Copilot editor integrations
  • Confirmed the /copilot_internal/v2/token endpoint is the standard token exchange used by VS Code and other Copilot clients
  • The fallback path (if (!response.ok) return oauthToken) ensures no regression for users where the exchange isn't needed

Copilot AI review requested due to automatic review settings February 18, 2026 19:39
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes GitHub Enterprise authentication by implementing proper JWT token exchange for the Copilot API. The issue (#3936) occurred because the code was sending raw OAuth tokens directly to the Copilot API, which works for github.com but fails for GitHub Enterprise instances that require a short-lived JWT obtained via the /copilot_internal/v2/token endpoint.

Changes:

  • Added JWT exchange logic with domain-aware caching and automatic refresh
  • Updated API requests to include required Editor-Version and Copilot-Integration-Id headers
  • Implemented fallback to raw OAuth token if JWT exchange fails

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +50 to +53
const data = (await response.json()) as {
token: string
expires_at: number
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON parsing and destructuring on lines 50-53 can throw an error if the response body is malformed or doesn't contain the expected fields. This could happen if the API changes or returns an error response in an unexpected format. The error would propagate uncaught, potentially crashing the authentication flow.

Consider adding error handling around the JSON parsing and validation of the required fields (token and expires_at). If the response is invalid, fall back to the OAuth token similar to the !response.ok case on line 48.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +61
async function exchangeForCopilotJWT(
domain: string,
oauthToken: string,
): Promise<string> {
const cached = jwtCache.get(domain)
if (cached && cached.expiresAt > Date.now() / 1000 + JWT_REFRESH_BUFFER_SECONDS)
return cached.token

const apiHost =
domain === "github.com" ? "api.github.com" : `api.${domain}`
const response = await fetch(
`https://${apiHost}/copilot_internal/v2/token`,
{
headers: {
Authorization: `token ${oauthToken}`,
Accept: "application/json",
"User-Agent": `opencode/${Installation.VERSION}`,
"Editor-Version": `opencode/${Installation.VERSION}`,
},
},
)

if (!response.ok) return oauthToken

const data = (await response.json()) as {
token: string
expires_at: number
}

jwtCache.set(domain, {
token: data.token,
expiresAt: data.expires_at,
})

return data.token
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new JWT exchange functionality lacks test coverage. Given that this is a critical authentication flow that fixes a bug for GitHub Enterprise users, consider adding tests that verify:

  1. JWT is exchanged and cached correctly for both github.com and Enterprise domains
  2. Cached JWT is reused when not expired
  3. JWT is refreshed when approaching expiration (within the buffer time)
  4. Fallback to OAuth token occurs when the exchange fails
  5. Correct API host is constructed for different domain types

The codebase has comprehensive test coverage for other functionality, as evidenced by the test/ directory structure.

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +166
const domain = enterpriseUrl
? normalizeDomain(enterpriseUrl)
: "github.com"
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The enterpriseUrl is normalized twice unnecessarily. It's normalized during OAuth authorization (line 241: domain = normalizeDomain(enterpriseUrl!)) and stored as result.enterpriseUrl = domain (line 316). Then when retrieved from auth info, it's normalized again (lines 164-166).

While this is harmless because normalization should be idempotent, it creates confusion about whether enterpriseUrl contains a raw URL or a normalized domain. Consider either:

  1. Storing the raw URL and normalizing only when needed, or
  2. Documenting that enterpriseUrl always contains a normalized domain (no protocol, no trailing slash)
Suggested change
const domain = enterpriseUrl
? normalizeDomain(enterpriseUrl)
: "github.com"
// enterpriseUrl is stored in normalized form (no protocol, no trailing slash),
// so we can use it directly here without re-normalizing.
const domain = enterpriseUrl ?? "github.com"

Copilot uses AI. Check for mistakes.
Authorization: `token ${oauthToken}`,
Accept: "application/json",
"User-Agent": `opencode/${Installation.VERSION}`,
"Editor-Version": `opencode/${Installation.VERSION}`,
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JWT exchange request includes "Editor-Version" header but not "Copilot-Integration-Id" header, while the actual API requests (lines 174-175) include both. For consistency and proper identification to GitHub's API, consider including "Copilot-Integration-Id" in the exchange request as well. This helps GitHub track which client is making the token exchange request.

Suggested change
"Editor-Version": `opencode/${Installation.VERSION}`,
"Editor-Version": `opencode/${Installation.VERSION}`,
"Copilot-Integration-Id": "opencode",

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +61
async function exchangeForCopilotJWT(
domain: string,
oauthToken: string,
): Promise<string> {
const cached = jwtCache.get(domain)
if (cached && cached.expiresAt > Date.now() / 1000 + JWT_REFRESH_BUFFER_SECONDS)
return cached.token

const apiHost =
domain === "github.com" ? "api.github.com" : `api.${domain}`
const response = await fetch(
`https://${apiHost}/copilot_internal/v2/token`,
{
headers: {
Authorization: `token ${oauthToken}`,
Accept: "application/json",
"User-Agent": `opencode/${Installation.VERSION}`,
"Editor-Version": `opencode/${Installation.VERSION}`,
},
},
)

if (!response.ok) return oauthToken

const data = (await response.json()) as {
token: string
expires_at: number
}

jwtCache.set(domain, {
token: data.token,
expiresAt: data.expires_at,
})

return data.token
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JWT cache check and exchange logic has a race condition. If multiple concurrent requests occur for the same domain when the cache is empty or expired, they will all pass the cache check and initiate separate token exchange requests to the GitHub API. This can lead to unnecessary API calls and potential rate limiting issues.

Consider implementing a pattern similar to the one used in file/time.ts where a Map of Promises is used to ensure only one exchange happens at a time per domain. Store the exchange promise itself in the cache during the request, so concurrent calls can await the same promise.

Copilot uses AI. Check for mistakes.
Authorization: `Bearer ${info.refresh}`,
Authorization: `Bearer ${token}`,
"Editor-Version": `opencode/${Installation.VERSION}`,
"Copilot-Integration-Id": "opencode-chat",
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Copilot-Integration-Id" header value "opencode-chat" is hardcoded. While this may be intentional for identification purposes, consider whether this should be configurable or include version information for better tracking and debugging of API requests. This can help GitHub support identify which version of OpenCode is making requests if issues arise.

Copilot uses AI. Check for mistakes.
},
)

if (!response.ok) return oauthToken
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the JWT exchange request fails (line 48), the function silently falls back to returning the raw OAuth token without logging the failure. This makes debugging authentication issues difficult, especially for GitHub Enterprise deployments where the JWT exchange is required.

Consider logging the error response (status code and message) before falling back, so administrators can diagnose configuration or connectivity issues with their Enterprise instance. This would have helped debug the issue described in #3936 more quickly.

Suggested change
if (!response.ok) return oauthToken
if (!response.ok) {
let errorText = ""
try {
errorText = await response.text()
} catch {
errorText = "<failed to read error response body>"
}
console.error(
`[copilot] Failed to exchange OAuth token for Copilot JWT for domain "${domain}" (status ${response.status} ${response.statusText}): ${errorText}`,
)
return oauthToken
}

Copilot uses AI. Check for mistakes.
@rekram1-node
Copy link
Collaborator

Also adds Editor-Version and Copilot-Integration-Id headers that the Copilot API expects.

Why are we adding these? They explicitly instructed us NOT to have these.

@rekram1-node
Copy link
Collaborator

Do u actually test w/ a github enterprise account? Because I worked w/ several others who do have one and Im not sure this is accurate at all...

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.

Github Enterprise authorization

2 participants

Comments