FN-5699: fix github-copilot device-code login callback handling
Prevent dashboard auth initiation from crashing when Copilot emits device-code callbacks. - wire optional auth callbacks (`onDeviceCode`, `onSelect`) through dashboard auth route typing - capture and return device-code payload from either `onDeviceCode` or parsed Copilot instructions when `/api/auth/login` starts - guard auth-init promise settlement to avoid double resolve/reject races and normalize async login errors - add regression coverage for Copilot device-code callback handling in auth route tests - sync roadmap plugin schema-version test expectation from 94 to 95 to keep suite assertions aligned Files changed: .changeset/fn-5699-copilot-device-code-callback.md | 5 ++ packages/dashboard/src/__tests__/routes-auth.test.ts | 21 ++++++++ packages/dashboard/src/routes.ts | 7 +++ packages/dashboard/src/routes/register-auth-routes.ts | 62 +++++++++++++++++----- plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts | 4 +- 5 files changed, 83 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-5699 Fusion-Task-Lineage: 24bf59f2-3b22-4087-96ca-35409fe00895
This commit is contained in:
5
.changeset/fn-5699-copilot-device-code-callback.md
Normal file
5
.changeset/fn-5699-copilot-device-code-callback.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix dashboard OAuth login for `github-copilot` when upstream auth storage invokes device-code callbacks. The `/api/auth/login` route now provides the expected callback wiring and preserves `deviceCode: { userCode, verificationUri }` in responses so Copilot login no longer crashes with `options.onDeviceCode is not a function`.
|
||||
@@ -1289,6 +1289,27 @@ describe("POST /auth/login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handles github-copilot device-code callback without crashing", async () => {
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||||
callbacks.onDeviceCode({
|
||||
userCode: "WXYZ-9876",
|
||||
verificationUri: "https://github.com/login/device",
|
||||
});
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "github-copilot" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deviceCode).toEqual({
|
||||
userCode: "WXYZ-9876",
|
||||
verificationUri: "https://github.com/login/device",
|
||||
});
|
||||
expect(res.body.url).toBe("https://github.com/login/device");
|
||||
});
|
||||
|
||||
it("auto-resolves first onPrompt invocation for github-copilot with blank input", async () => {
|
||||
let promptValue: string | undefined;
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
|
||||
|
||||
@@ -206,9 +206,16 @@ export interface AuthStorageLike {
|
||||
providerId: string,
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onDeviceCode?: (info: {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}) => void;
|
||||
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
onSelect?: (prompt: { message: string; options: Array<{ id: string; label: string }> }) => Promise<string | undefined>;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<void>;
|
||||
|
||||
@@ -798,32 +798,60 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
|
||||
let autoPromptConsumed = false;
|
||||
|
||||
// We need to get the URL from the onAuth callback before responding.
|
||||
// We need to get auth kickoff info from callbacks before responding.
|
||||
// The login() call continues in the background until the user completes OAuth.
|
||||
let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
|
||||
let authReject: (err: Error) => void;
|
||||
let authSettled = false;
|
||||
const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => {
|
||||
authResolve = resolve;
|
||||
authReject = reject;
|
||||
});
|
||||
const resolveAuthInfo = (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => {
|
||||
if (authSettled) return;
|
||||
authSettled = true;
|
||||
authResolve(info);
|
||||
};
|
||||
const rejectAuthInfo = (err: Error) => {
|
||||
if (authSettled) return;
|
||||
authSettled = true;
|
||||
authReject(err);
|
||||
};
|
||||
|
||||
let resolvedDeviceCode: DeviceCodeInfo | undefined;
|
||||
|
||||
// Start login flow in background — don't await the full login
|
||||
const loginPromise = storage.login(provider, {
|
||||
onAuth: (info) => {
|
||||
const parsedUserCode =
|
||||
provider === "github-copilot" && info.instructions
|
||||
? parseGitHubCopilotDeviceCode(info.instructions)
|
||||
: undefined;
|
||||
const deviceCode = parsedUserCode
|
||||
? {
|
||||
if (!resolvedDeviceCode) {
|
||||
const parsedUserCode =
|
||||
provider === "github-copilot" && info.instructions
|
||||
? parseGitHubCopilotDeviceCode(info.instructions)
|
||||
: undefined;
|
||||
if (parsedUserCode) {
|
||||
resolvedDeviceCode = {
|
||||
userCode: parsedUserCode,
|
||||
verificationUri: info.url,
|
||||
}
|
||||
: undefined;
|
||||
authResolve({
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
resolveAuthInfo({
|
||||
url: info.url,
|
||||
instructions: appendManualCodeHint(info.instructions, provider, origin),
|
||||
deviceCode,
|
||||
deviceCode: resolvedDeviceCode,
|
||||
});
|
||||
},
|
||||
onDeviceCode: (info) => {
|
||||
resolvedDeviceCode = {
|
||||
userCode: info.userCode,
|
||||
verificationUri: info.verificationUri,
|
||||
};
|
||||
|
||||
resolveAuthInfo({
|
||||
url: info.verificationUri,
|
||||
instructions: appendManualCodeHint(undefined, provider, origin),
|
||||
deviceCode: resolvedDeviceCode,
|
||||
});
|
||||
},
|
||||
onPrompt: async (_prompt) => {
|
||||
@@ -838,21 +866,27 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
// to race pasted codes against the localhost callback server.
|
||||
onManualCodeInput: async () => await pendingLogin.inputPromise,
|
||||
onProgress: () => {}, // no-op for web UI
|
||||
onSelect: async (prompt) => {
|
||||
if (prompt.options.length === 1) {
|
||||
return prompt.options[0]?.id;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Race: either we get the auth URL or the login completes/fails first
|
||||
const timeout = setTimeout(() => {
|
||||
authReject(new Error("Login initiation timed out"));
|
||||
rejectAuthInfo(new Error("Login initiation timed out"));
|
||||
}, 30_000);
|
||||
|
||||
loginPromise
|
||||
.then(() => {
|
||||
// Login completed (user finished OAuth in browser)
|
||||
})
|
||||
.catch((err) => {
|
||||
.catch((err: unknown) => {
|
||||
// Login failed — also reject auth URL if not yet received
|
||||
authReject(err);
|
||||
rejectAuthInfo(err instanceof Error ? err : new Error(String(err)));
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
||||
});
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 94 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(94);
|
||||
it("schema version is 95 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user