feat(ai): refactor device code login for copilot

This commit is contained in:
Vegard Stikbakke
2026-05-20 09:30:58 +02:00
parent ced73b396a
commit c554364c2a
11 changed files with 316 additions and 129 deletions

View File

@@ -29,7 +29,62 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.useRealTimers();
});
it("waits before the first poll and increases the safety margin after slow_down", async () => {
it("reports device-code details through onDeviceCode", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
const url = getUrl(input);
if (url.endsWith("/login/device/code")) {
return jsonResponse({
device_code: "device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
interval: 1,
expires_in: 900,
});
}
if (url.endsWith("/login/oauth/access_token")) {
return jsonResponse({ access_token: "ghu_refresh_token" });
}
if (url.includes("/copilot_internal/v2/token")) {
return jsonResponse({
token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;",
expires_at: 9999999999,
});
}
if (url.includes("/models/") && url.endsWith("/policy")) {
return new Response("", { status: 200 });
}
throw new Error(`Unexpected fetch URL: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const onDeviceCode = vi.fn();
const loginPromise = loginGitHubCopilot({
onDeviceCode,
onPrompt: async () => "",
});
await vi.advanceTimersByTimeAsync(0);
expect(onDeviceCode).toHaveBeenCalledWith({
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
intervalSeconds: 1,
expiresInSeconds: 900,
});
await vi.advanceTimersByTimeAsync(1000);
await loginPromise;
});
it("waits before the first poll and increases the interval after slow_down", async () => {
vi.useFakeTimers();
const startTime = new Date("2026-03-09T00:00:00Z");
vi.setSystemTime(startTime);
@@ -37,7 +92,7 @@ describe("GitHub Copilot OAuth device flow", () => {
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
jsonResponse({ error: "slow_down", error_description: "slow down" }),
jsonResponse({ access_token: "ghu_refresh_token" }),
];
@@ -95,7 +150,7 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
onAuth: () => {},
onDeviceCode: () => {},
onPrompt: async () => "",
onProgress: () => {},
});
@@ -103,28 +158,28 @@ describe("GitHub Copilot OAuth device flow", () => {
await vi.advanceTimersByTimeAsync(0);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(5999);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(5999);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(accessTokenPollTimes).toHaveLength(2);
await vi.advanceTimersByTimeAsync(13999);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
await loginPromise;
expect(accessTokenPollTimes).toEqual([
startTime.getTime() + 6000,
startTime.getTime() + 12000,
startTime.getTime() + 26000,
startTime.getTime() + 5000,
startTime.getTime() + 10000,
startTime.getTime() + 20000,
]);
});
@@ -135,8 +190,8 @@ describe("GitHub Copilot OAuth device flow", () => {
const accessTokenPollTimes: number[] = [];
const accessTokenResponses = [
jsonResponse({ error: "slow_down", error_description: "slow down", interval: 10 }),
jsonResponse({ error: "slow_down", error_description: "still too fast", interval: 15 }),
jsonResponse({ error: "slow_down", error_description: "slow down" }),
jsonResponse({ error: "slow_down", error_description: "still too fast" }),
jsonResponse({ error: "authorization_pending", error_description: "pending" }),
];
@@ -168,28 +223,28 @@ describe("GitHub Copilot OAuth device flow", () => {
vi.stubGlobal("fetch", fetchMock);
const loginPromise = loginGitHubCopilot({
onAuth: () => {},
onDeviceCode: () => {},
onPrompt: async () => "",
});
const rejection = expect(loginPromise).rejects.toThrow(
/Device flow timed out after one or more slow_down responses/,
);
await vi.advanceTimersByTimeAsync(6000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000]);
await vi.advanceTimersByTimeAsync(5000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000]);
await vi.advanceTimersByTimeAsync(14000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
await vi.advanceTimersByTimeAsync(10000);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(4999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 6000, startTime.getTime() + 20000]);
await vi.advanceTimersByTimeAsync(9999);
expect(accessTokenPollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 15000]);
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(accessTokenPollTimes).toEqual([
startTime.getTime() + 6000,
startTime.getTime() + 20000,
startTime.getTime() + 5000,
startTime.getTime() + 15000,
startTime.getTime() + 25000,
]);
});

View File

@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts";
describe("OAuth device-code polling", () => {
afterEach(() => {
vi.useRealTimers();
});
it("waits before the first poll and returns the completed value", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-09T00:00:00Z"));
const pollTimes: number[] = [];
const poll = vi.fn(async () => {
pollTimes.push(Date.now());
return pollTimes.length === 1
? { status: "pending" as const }
: { status: "complete" as const, accessToken: "token" };
});
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 2,
expiresInSeconds: 30,
poll,
});
await vi.advanceTimersByTimeAsync(1999);
expect(pollTimes).toEqual([]);
await vi.advanceTimersByTimeAsync(1);
expect(pollTimes).toEqual([new Date("2026-03-09T00:00:02Z").getTime()]);
await vi.advanceTimersByTimeAsync(2000);
await expect(resultPromise).resolves.toBe("token");
expect(pollTimes).toEqual([
new Date("2026-03-09T00:00:02Z").getTime(),
new Date("2026-03-09T00:00:04Z").getTime(),
]);
});
it("cancels an in-flight wait", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const resultPromise = pollOAuthDeviceCodeFlow({
intervalSeconds: 5,
expiresInSeconds: 30,
poll: async () => ({ status: "pending" }),
signal: controller.signal,
});
controller.abort();
await expect(resultPromise).rejects.toThrow("Login cancelled");
});
});