diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index df21e6d5d6..9283925c0b 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -2189,6 +2189,7 @@ describe("POST /auth/login", () => { }); it.each([ + ["http query", "http://localhost:53692/callback?code=http-code&state=expected-state", "code=http-code&state=expected-state"], ["fragment", "http://localhost:53692/callback#code=fragment-code&state=expected-state", "code=fragment-code&state=expected-state"], ["schemeless", "localhost:53692/callback?code=schemeless-code&state=expected-state", "code=schemeless-code&state=expected-state"], ])("normalizes Anthropic subscription pasted callback URLs with %s parameters", async (_case, callbackUrl, expectedInput) => { @@ -2496,6 +2497,55 @@ describe("POST /auth/manual-code", () => { }); }); + it("delivers pasted Anthropic callback URLs to the local OAuth listener", async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "ok" }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + let submittedCode: string | undefined; + (authStorage.login as ReturnType).mockImplementation( + async (_provider: string, callbacks: { + onAuth: (info: { url: string; instructions?: string }) => void; + onManualCodeInput?: () => Promise; + }) => { + callbacks.onAuth({ + url: "https://claude.ai/oauth/authorize?state=anthropic-state&redirect_uri=http%3A%2F%2Flocalhost%3A53692%2Fcallback", + }); + submittedCode = await callbacks.onManualCodeInput?.(); + }, + ); + + try { + const app = buildApp(); + const loginRes = await REQUEST( + app, + "POST", + "/api/auth/login", + JSON.stringify({ provider: "anthropic-subscription", origin: "https://remote.example.com" }), + { "Content-Type": "application/json" }, + ); + + expect(loginRes.status).toBe(200); + + const submitRes = await REQUEST( + app, + "POST", + "/api/auth/manual-code", + JSON.stringify({ provider: "anthropic-subscription", code: "http://localhost:53692/callback?code=anthropic-code&state=anthropic-state" }), + { "Content-Type": "application/json" }, + ); + + expect(submitRes.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:53692/callback?code=anthropic-code&state=anthropic-state"); + expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ method: "GET" })); + await vi.waitFor(() => { + expect(submittedCode).toBe("code=anthropic-code&state=anthropic-state"); + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("submits pasted manual code for anthropic login", async () => { let submittedCode: string | undefined; (authStorage.login as ReturnType).mockImplementation( diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 35a29ff173..503b3c30fc 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -251,6 +251,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return providerId === "github-copilot"; } + function parseManualOAuthCallbackUrl(input: string): URL | undefined { + const trimmed = input.trim(); + const candidates = [trimmed]; + if (/^(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/)/i.test(trimmed)) { + candidates.unshift(`http://${trimmed}`); + } + + for (const candidate of candidates) { + try { + return new URL(candidate); + } catch { + // Try the next representation. + } + } + return undefined; + } + function normalizeManualOAuthInputForProvider(providerId: string, input: string): string { if (providerId !== "anthropic" && providerId !== "openai-codex") { return input.trim(); @@ -262,10 +279,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } try { - const url = new URL(trimmed); + const url = parseManualOAuthCallbackUrl(trimmed); + if (!url) { + throw new Error("not a URL"); + } const searchCode = url.searchParams.get("code"); if (searchCode) { - if (url.protocol === "http:" || url.protocol === "https:") { + if (providerId === "openai-codex" && (url.protocol === "http:" || url.protocol === "https:")) { return trimmed; } const normalized = new URLSearchParams(); @@ -291,6 +311,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { FNXC:ProviderAuth 2026-07-04-00:00: Anthropic subscription and Codex pasted-login flows must accept the exact browser address bar after redirect, including providers/browsers that place OAuth `code` and `state` in the URL fragment or omit the URL scheme. The upstream CLI parser treats a syntactically valid URL as search-only and schemeless localhost text as raw parameters, so normalize callback inputs to query-param text before resolving the pending manual-code prompt. + Also keep the raw callback URL parseable for server-side callback delivery when the browser cannot reach the local OAuth listener itself. */ const normalized = new URLSearchParams(); normalized.set("code", hashCode); @@ -320,6 +341,33 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } } + async function deliverManualOAuthCallbackToLocalListener(providerId: string, input: string): Promise { + if (providerId !== "anthropic") { + return; + } + + const url = parseManualOAuthCallbackUrl(input); + if (!url || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) { + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state || !url.port) { + return; + } + + const callbackUrl = new URL(`http://127.0.0.1:${url.port}${url.pathname}`); + callbackUrl.searchParams.set("code", code); + callbackUrl.searchParams.set("state", state); + + try { + await fetch(callbackUrl, { method: "GET", signal: AbortSignal.timeout(3_000) }); + } catch { + // Fall back to resolving the pending manual-code prompt below. + } + } + function selectOauthOption( providerId: string, prompt: { options: Array<{ id: string; label?: string }> }, @@ -1206,7 +1254,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { * Body: { provider: string, code: string } * Response: { success: true, submitted: boolean } */ - router.post("/auth/manual-code", (req, res) => { + router.post("/auth/manual-code", async (req, res) => { try { const { provider, code } = req.body; if (!provider || typeof provider !== "string") { @@ -1226,8 +1274,10 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return; } + const storageProvider = toOauthLoginProviderId(provider); activeLogin.inputSubmitted = true; - activeLogin.resolveInput(normalizeManualOAuthInputForProvider(toOauthLoginProviderId(provider), code)); + await deliverManualOAuthCallbackToLocalListener(storageProvider, code); + activeLogin.resolveInput(normalizeManualOAuthInputForProvider(storageProvider, code)); res.json({ success: true, submitted: true }); } catch (err: unknown) { if (err instanceof ApiError) {