fix: normalize Anthropic subscription callback URLs
This commit is contained in:
@@ -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<typeof vi.fn>).mockImplementation(
|
||||
async (_provider: string, callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => {
|
||||
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<typeof vi.fn>).mockImplementation(
|
||||
|
||||
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user