mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): fall back to a free port for Anthropic OAuth callback
Try 53692 first so forwarded setups keep working, then let the OS pick a free loopback port when 53692 cannot be bound (e.g. Hyper-V/WSL port exclusions on Windows). The authorize URL, paste prompt and token exchange use the bound redirect URI. closes #10571
This commit is contained in:
parent
2db5e359bf
commit
8d8ae2fc24
3 changed files with 105 additions and 42 deletions
|
|
@ -24,6 +24,7 @@
|
|||
- Reduced context-limit request failures by estimating input at 3.5 characters per token instead of 4 when calculating output limits ([#10497](https://github.com/earendil-works/pi/issues/10497))
|
||||
- Fixed Radius models disabled by an organization owner still being listed: a fetched or cached Radius catalog now replaces the shipped default catalog instead of being merged into it
|
||||
- Fixed OpenAI provider type-checking with cached catalogs that contain no classifier models
|
||||
- Fixed Anthropic browser login failing with "localhost refused to connect" when port 53692 is reserved or in use, for example by Hyper-V/WSL port exclusions on Windows: login now falls back to a free loopback port ([#10571](https://github.com/earendil-works/pi/issues/10571))
|
||||
|
||||
## [1.0.4] - 2026-10-05
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
// Preferred so the port can be forwarded into containers or over SSH. Anthropic accepts any loopback port,
|
||||
// so login falls back to a free port when this one cannot be bound (#10571).
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
|
|
@ -137,22 +139,29 @@ async function exchangeAuthorizationCode(
|
|||
|
||||
async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const callback = await startOAuthCallbackServer({
|
||||
providerName: "Anthropic",
|
||||
host: CALLBACK_HOST,
|
||||
port: CALLBACK_PORT,
|
||||
path: CALLBACK_PATH,
|
||||
state: verifier,
|
||||
complete: async (code) => code,
|
||||
signal: interaction.signal,
|
||||
}).catch(() => undefined);
|
||||
const startCallbackServer = (port: number) =>
|
||||
startOAuthCallbackServer({
|
||||
providerName: "Anthropic",
|
||||
host: CALLBACK_HOST,
|
||||
port,
|
||||
path: CALLBACK_PATH,
|
||||
redirectHost: "localhost",
|
||||
state: verifier,
|
||||
complete: async (code) => code,
|
||||
signal: interaction.signal,
|
||||
});
|
||||
// Without a callback server, login continues with the pasted redirect URL.
|
||||
const callback = await startCallbackServer(CALLBACK_PORT)
|
||||
.catch(() => startCallbackServer(0))
|
||||
.catch(() => undefined);
|
||||
const redirectUri = callback?.redirectUri ?? REDIRECT_URI;
|
||||
|
||||
try {
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
redirect_uri: redirectUri,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
|
|
@ -167,7 +176,7 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAu
|
|||
|
||||
const result = await waitForCallbackOrManualInput(interaction, callback, {
|
||||
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
|
||||
placeholder: REDIRECT_URI,
|
||||
placeholder: redirectUri,
|
||||
});
|
||||
let code: string | undefined;
|
||||
let state = verifier;
|
||||
|
|
@ -182,7 +191,7 @@ async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAu
|
|||
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
||||
return await exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal);
|
||||
return await exchangeAuthorizationCode(code, state, verifier, redirectUri, interaction.signal);
|
||||
} finally {
|
||||
callback?.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
|
||||
|
|
@ -210,37 +211,89 @@ describe.sequential("Anthropic OAuth", () => {
|
|||
});
|
||||
|
||||
it("completes login through the browser callback and shows the sign-in page", async () => {
|
||||
let exchangedCode: string | undefined;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token")
|
||||
return nativeFetch(input as string, init);
|
||||
exchangedCode = getJsonBody(init).code;
|
||||
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
|
||||
}),
|
||||
);
|
||||
const login = await loginThroughBrowserCallback();
|
||||
|
||||
let callbackPage: Promise<Response> | undefined;
|
||||
const credential = await anthropicOAuth.login({
|
||||
signal: neverAbortedSignal,
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
const state = new URL(event.url).searchParams.get("state") ?? "";
|
||||
callbackPage = nativeFetch(`http://127.0.0.1:53692/callback?code=browser-code&state=${state}`);
|
||||
},
|
||||
prompt: (prompt) =>
|
||||
prompt.type === "select"
|
||||
? Promise.resolve("browser")
|
||||
: new Promise((_, reject) => {
|
||||
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
||||
}),
|
||||
});
|
||||
expect(login.credential.access).toBe("access");
|
||||
expect(login.exchangedCode).toBe("browser-code");
|
||||
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
|
||||
expect(login.pageStatus).toBe(200);
|
||||
expect(login.pageText).toContain("Signed in to Anthropic.");
|
||||
});
|
||||
|
||||
expect(credential.access).toBe("access");
|
||||
expect(exchangedCode).toBe("browser-code");
|
||||
const response = await callbackPage;
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.text()).toContain("Signed in to Anthropic.");
|
||||
// #10571
|
||||
it("falls back to a free callback port when the preferred port cannot be bound", async () => {
|
||||
const blocker = createServer();
|
||||
await listen(blocker, 53692);
|
||||
try {
|
||||
const login = await loginThroughBrowserCallback();
|
||||
const redirectUri = new URL(login.redirectUri);
|
||||
|
||||
expect(redirectUri.hostname).toBe("localhost");
|
||||
expect(redirectUri.pathname).toBe("/callback");
|
||||
expect(redirectUri.port).not.toBe("53692");
|
||||
expect(login.credential.access).toBe("access");
|
||||
expect(login.exchangedRedirectUri).toBe(login.redirectUri);
|
||||
expect(login.pageStatus).toBe(200);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function listen(server: Server, port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loginThroughBrowserCallback() {
|
||||
let exchangedCode: string | undefined;
|
||||
let exchangedRedirectUri: string | undefined;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||
if (getUrl(input) !== "https://platform.claude.com/v1/oauth/token") return nativeFetch(input as string, init);
|
||||
const body = getJsonBody(init);
|
||||
exchangedCode = body.code;
|
||||
exchangedRedirectUri = body.redirect_uri;
|
||||
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
|
||||
}),
|
||||
);
|
||||
|
||||
let redirectUri = "";
|
||||
let callbackPage: Promise<Response> | undefined;
|
||||
const credential = await anthropicOAuth.login({
|
||||
signal: neverAbortedSignal,
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
const params = new URL(event.url).searchParams;
|
||||
redirectUri = params.get("redirect_uri") ?? "";
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.hostname = "127.0.0.1";
|
||||
callbackUrl.search = new URLSearchParams({
|
||||
code: "browser-code",
|
||||
state: params.get("state") ?? "",
|
||||
}).toString();
|
||||
callbackPage = nativeFetch(callbackUrl);
|
||||
},
|
||||
prompt: (prompt) =>
|
||||
prompt.type === "select"
|
||||
? Promise.resolve("browser")
|
||||
: new Promise((_, reject) => {
|
||||
prompt.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
||||
}),
|
||||
});
|
||||
const page = await callbackPage;
|
||||
return {
|
||||
credential,
|
||||
redirectUri,
|
||||
exchangedCode,
|
||||
exchangedRedirectUri,
|
||||
pageStatus: page?.status,
|
||||
pageText: await page?.text(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue