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 () => {
|
it("auto-resolves first onPrompt invocation for github-copilot with blank input", async () => {
|
||||||
let promptValue: string | undefined;
|
let promptValue: string | undefined;
|
||||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
|
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(async (_provider: string, callbacks: any) => {
|
||||||
|
|||||||
@@ -206,9 +206,16 @@ export interface AuthStorageLike {
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
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>;
|
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||||
onManualCodeInput?: () => Promise<string>;
|
onManualCodeInput?: () => Promise<string>;
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
|
onSelect?: (prompt: { message: string; options: Array<{ id: string; label: string }> }) => Promise<string | undefined>;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
},
|
},
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|||||||
@@ -798,32 +798,60 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
let autoPromptConsumed = false;
|
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.
|
// The login() call continues in the background until the user completes OAuth.
|
||||||
let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
|
let authResolve: (info: { url: string; instructions?: string; deviceCode?: DeviceCodeInfo }) => void;
|
||||||
let authReject: (err: Error) => void;
|
let authReject: (err: Error) => void;
|
||||||
|
let authSettled = false;
|
||||||
const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => {
|
const authUrlPromise = new Promise<{ url: string; instructions?: string; deviceCode?: DeviceCodeInfo }>((resolve, reject) => {
|
||||||
authResolve = resolve;
|
authResolve = resolve;
|
||||||
authReject = reject;
|
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
|
// Start login flow in background — don't await the full login
|
||||||
const loginPromise = storage.login(provider, {
|
const loginPromise = storage.login(provider, {
|
||||||
onAuth: (info) => {
|
onAuth: (info) => {
|
||||||
const parsedUserCode =
|
if (!resolvedDeviceCode) {
|
||||||
provider === "github-copilot" && info.instructions
|
const parsedUserCode =
|
||||||
? parseGitHubCopilotDeviceCode(info.instructions)
|
provider === "github-copilot" && info.instructions
|
||||||
: undefined;
|
? parseGitHubCopilotDeviceCode(info.instructions)
|
||||||
const deviceCode = parsedUserCode
|
: undefined;
|
||||||
? {
|
if (parsedUserCode) {
|
||||||
|
resolvedDeviceCode = {
|
||||||
userCode: parsedUserCode,
|
userCode: parsedUserCode,
|
||||||
verificationUri: info.url,
|
verificationUri: info.url,
|
||||||
}
|
};
|
||||||
: undefined;
|
}
|
||||||
authResolve({
|
}
|
||||||
|
|
||||||
|
resolveAuthInfo({
|
||||||
url: info.url,
|
url: info.url,
|
||||||
instructions: appendManualCodeHint(info.instructions, provider, origin),
|
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) => {
|
onPrompt: async (_prompt) => {
|
||||||
@@ -838,21 +866,27 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
// to race pasted codes against the localhost callback server.
|
// to race pasted codes against the localhost callback server.
|
||||||
onManualCodeInput: async () => await pendingLogin.inputPromise,
|
onManualCodeInput: async () => await pendingLogin.inputPromise,
|
||||||
onProgress: () => {}, // no-op for web UI
|
onProgress: () => {}, // no-op for web UI
|
||||||
|
onSelect: async (prompt) => {
|
||||||
|
if (prompt.options.length === 1) {
|
||||||
|
return prompt.options[0]?.id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Race: either we get the auth URL or the login completes/fails first
|
// Race: either we get the auth URL or the login completes/fails first
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
authReject(new Error("Login initiation timed out"));
|
rejectAuthInfo(new Error("Login initiation timed out"));
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
loginPromise
|
loginPromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Login completed (user finished OAuth in browser)
|
// Login completed (user finished OAuth in browser)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err: unknown) => {
|
||||||
// Login failed — also reject auth URL if not yet received
|
// Login failed — also reject auth URL if not yet received
|
||||||
authReject(err);
|
rejectAuthInfo(err instanceof Error ? err : new Error(String(err)));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 94 after init", () => {
|
it("schema version is 95 after init", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(94);
|
expect(db.getSchemaVersion()).toBe(95);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user