Skip to content

Commit 98c9d11

Browse files
committed
feat: support both GitHub.com and Enterprise with shared implementation
Refactors plugin to use a shared factory function that handles both GitHub Copilot (github.com) and GitHub Copilot Enterprise deployments. Changes: - Create shared createCopilotPlugin factory function - Export CopilotAuthPlugin for github.com (oauth type) - Export CopilotEnterpriseAuthPlugin for enterprise (custom type) - Enterprise auth uses custom type with enterpriseUrl prompt - Both plugins share token management and request handling logic - Dynamic baseURL construction based on enterprise URL from auth data This allows users to authenticate to both GitHub.com and Enterprise simultaneously while minimizing code duplication.
1 parent 476ce15 commit 98c9d11

1 file changed

Lines changed: 174 additions & 77 deletions

File tree

index.mjs

Lines changed: 174 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
/**
2-
* @type {import('@opencode-ai/plugin').Plugin}
3-
*/
4-
export async function CopilotAuthPlugin({ client }) {
1+
function createCopilotPlugin(client, { providerId, isEnterprise }) {
52
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
63
const HEADERS = {
74
"User-Agent": "GitHubCopilotChat/0.35.0",
@@ -10,53 +7,23 @@ export async function CopilotAuthPlugin({ client }) {
107
"Copilot-Integration-Id": "vscode-chat",
118
};
129

13-
/**
14-
* Normalizes a domain by removing protocol and trailing slashes
15-
*/
1610
function normalizeDomain(url) {
1711
return url
1812
.replace(/^https?:\/\//, "")
1913
.replace(/\/$/, "");
2014
}
2115

22-
/**
23-
* Gets the base URL from auth data, provider config, or defaults to github.com
24-
* Priority: auth data > config > github.com
25-
*/
26-
async function getBaseUrl(providerId, authInfo) {
27-
try {
28-
// First check auth data (set by core during authentication)
29-
if (authInfo && authInfo.enterpriseUrl) {
30-
return normalizeDomain(authInfo.enterpriseUrl);
31-
}
32-
33-
// Then check config
34-
const config = await client.config.get();
35-
const providerConfig = config?.provider?.[providerId];
36-
const configUrl = providerConfig?.options?.enterpriseUrl;
37-
38-
return configUrl ? normalizeDomain(configUrl) : "github.com";
39-
} catch {
40-
return "github.com";
41-
}
42-
}
43-
44-
/**
45-
* Constructs URLs based on the base URL (github.com or enterprise)
46-
*/
47-
async function getUrls(providerId, authInfo) {
48-
const baseUrl = await getBaseUrl(providerId, authInfo);
49-
16+
function getUrls(domain) {
5017
return {
51-
DEVICE_CODE_URL: `https://${baseUrl}/login/device/code`,
52-
ACCESS_TOKEN_URL: `https://${baseUrl}/login/oauth/access_token`,
53-
COPILOT_API_KEY_URL: `https://api.${baseUrl}/copilot_internal/v2/token`,
18+
DEVICE_CODE_URL: `https://${domain}/login/device/code`,
19+
ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
20+
COPILOT_API_KEY_URL: `https://api.${domain}/copilot_internal/v2/token`,
5421
};
5522
}
5623

5724
return {
5825
auth: {
59-
provider: "github-copilot",
26+
provider: providerId,
6027
loader: async (getAuth, provider) => {
6128
let info = await getAuth();
6229
if (!info || info.type !== "oauth") return {};
@@ -70,13 +37,24 @@ export async function CopilotAuthPlugin({ client }) {
7037
}
7138
}
7239

40+
// For enterprise, set baseURL dynamically based on enterpriseUrl in auth data
41+
const enterpriseUrl = info.enterpriseUrl;
42+
const baseURL = enterpriseUrl
43+
? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
44+
: undefined;
45+
7346
return {
47+
...(baseURL && { baseURL }),
7448
apiKey: "",
7549
async fetch(input, init) {
7650
const info = await getAuth();
7751
if (info.type !== "oauth") return {};
7852
if (!info.access || info.expires < Date.now()) {
79-
const urls = await getUrls(provider.id, info);
53+
const domain = info.enterpriseUrl
54+
? normalizeDomain(info.enterpriseUrl)
55+
: "github.com";
56+
const urls = getUrls(domain);
57+
8058
const response = await fetch(urls.COPILOT_API_KEY_URL, {
8159
headers: {
8260
Accept: "application/json",
@@ -98,6 +76,7 @@ export async function CopilotAuthPlugin({ client }) {
9876
refresh: info.refresh,
9977
access: tokenData.token,
10078
expires: tokenData.expires_at * 1000,
79+
...(info.enterpriseUrl && { enterpriseUrl: info.enterpriseUrl }),
10180
},
10281
});
10382
info.access = tokenData.token;
@@ -138,32 +117,65 @@ export async function CopilotAuthPlugin({ client }) {
138117
},
139118
};
140119
},
141-
methods: [
142-
{
143-
label: "Login with GitHub",
144-
type: "oauth",
145-
authorize: async () => {
146-
// During authorize, read from config only (no auth data exists yet)
147-
const urls = await getUrls("github-copilot", null);
148-
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
149-
method: "POST",
150-
headers: {
151-
Accept: "application/json",
152-
"Content-Type": "application/json",
153-
"User-Agent": "GitHubCopilotChat/0.35.0",
154-
},
155-
body: JSON.stringify({
156-
client_id: CLIENT_ID,
157-
scope: "read:user",
158-
}),
159-
});
160-
const deviceData = await deviceResponse.json();
161-
return {
162-
url: deviceData.verification_uri,
163-
instructions: `Enter code: ${deviceData.user_code}`,
164-
method: "auto",
165-
callback: async () => {
120+
methods: isEnterprise
121+
? [
122+
{
123+
type: "custom",
124+
label: "Login with GitHub Enterprise",
125+
prompts: [
126+
{
127+
key: "enterpriseUrl",
128+
message: "Enter your GitHub Enterprise URL or domain",
129+
placeholder: "github.company.com or https://github.company.com",
130+
validate: (value) => {
131+
if (!value) return "URL or domain is required";
132+
try {
133+
const url = value.includes("://")
134+
? new URL(value)
135+
: new URL(`https://${value}`);
136+
if (!url.hostname)
137+
return "Please enter a valid URL or domain";
138+
return undefined;
139+
} catch {
140+
return "Please enter a valid URL (e.g., github.company.com)";
141+
}
142+
},
143+
},
144+
],
145+
async authorize(inputs) {
146+
const enterpriseUrl = inputs.enterpriseUrl;
147+
const domain = normalizeDomain(enterpriseUrl);
148+
const urls = getUrls(domain);
149+
150+
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
151+
method: "POST",
152+
headers: {
153+
Accept: "application/json",
154+
"Content-Type": "application/json",
155+
"User-Agent": "GitHubCopilotChat/0.35.0",
156+
},
157+
body: JSON.stringify({
158+
client_id: CLIENT_ID,
159+
scope: "read:user",
160+
}),
161+
});
162+
163+
if (!deviceResponse.ok) {
164+
return { type: "failed" };
165+
}
166+
167+
const deviceData = await deviceResponse.json();
168+
169+
// Display URL and code for user
170+
console.log(`Go to: ${deviceData.verification_uri}`);
171+
console.log(`Enter code: ${deviceData.user_code}`);
172+
173+
// Poll for authorization
166174
while (true) {
175+
await new Promise((resolve) =>
176+
setTimeout(resolve, (deviceData.interval || 5) * 1000),
177+
);
178+
167179
const response = await fetch(urls.ACCESS_TOKEN_URL, {
168180
method: "POST",
169181
headers: {
@@ -186,31 +198,116 @@ export async function CopilotAuthPlugin({ client }) {
186198
if (data.access_token) {
187199
return {
188200
type: "success",
201+
auth_type: "oauth",
189202
refresh: data.access_token,
190203
access: "",
191204
expires: 0,
205+
enterpriseUrl: domain,
192206
};
193207
}
194208

195209
if (data.error === "authorization_pending") {
196-
await new Promise((resolve) =>
197-
setTimeout(resolve, deviceData.interval * 1000),
198-
);
199210
continue;
200211
}
201212

202213
if (data.error) return { type: "failed" };
203-
204-
await new Promise((resolve) =>
205-
setTimeout(resolve, deviceData.interval * 1000),
206-
);
207-
continue;
208214
}
209215
},
210-
};
211-
},
212-
},
213-
],
216+
},
217+
]
218+
: [
219+
{
220+
type: "oauth",
221+
label: "Login with GitHub",
222+
authorize: async () => {
223+
const urls = getUrls("github.com");
224+
225+
const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
226+
method: "POST",
227+
headers: {
228+
Accept: "application/json",
229+
"Content-Type": "application/json",
230+
"User-Agent": "GitHubCopilotChat/0.35.0",
231+
},
232+
body: JSON.stringify({
233+
client_id: CLIENT_ID,
234+
scope: "read:user",
235+
}),
236+
});
237+
const deviceData = await deviceResponse.json();
238+
return {
239+
url: deviceData.verification_uri,
240+
instructions: `Enter code: ${deviceData.user_code}`,
241+
method: "auto",
242+
callback: async () => {
243+
while (true) {
244+
const response = await fetch(urls.ACCESS_TOKEN_URL, {
245+
method: "POST",
246+
headers: {
247+
Accept: "application/json",
248+
"Content-Type": "application/json",
249+
"User-Agent": "GitHubCopilotChat/0.35.0",
250+
},
251+
body: JSON.stringify({
252+
client_id: CLIENT_ID,
253+
device_code: deviceData.device_code,
254+
grant_type:
255+
"urn:ietf:params:oauth:grant-type:device_code",
256+
}),
257+
});
258+
259+
if (!response.ok) return { type: "failed" };
260+
261+
const data = await response.json();
262+
263+
if (data.access_token) {
264+
return {
265+
type: "success",
266+
refresh: data.access_token,
267+
access: "",
268+
expires: 0,
269+
};
270+
}
271+
272+
if (data.error === "authorization_pending") {
273+
await new Promise((resolve) =>
274+
setTimeout(resolve, deviceData.interval * 1000),
275+
);
276+
continue;
277+
}
278+
279+
if (data.error) return { type: "failed" };
280+
281+
await new Promise((resolve) =>
282+
setTimeout(resolve, deviceData.interval * 1000),
283+
);
284+
continue;
285+
}
286+
},
287+
};
288+
},
289+
},
290+
],
214291
},
215292
};
216293
}
294+
295+
/**
296+
* @type {import('@opencode-ai/plugin').Plugin}
297+
*/
298+
export async function CopilotAuthPlugin({ client }) {
299+
return createCopilotPlugin(client, {
300+
providerId: "github-copilot",
301+
isEnterprise: false,
302+
});
303+
}
304+
305+
/**
306+
* @type {import('@opencode-ai/plugin').Plugin}
307+
*/
308+
export async function CopilotEnterpriseAuthPlugin({ client }) {
309+
return createCopilotPlugin(client, {
310+
providerId: "github-copilot-enterprise",
311+
isEnterprise: true,
312+
});
313+
}

0 commit comments

Comments
 (0)