fix(ai): fix anthropic oauth manual callback and refresh flow closes #2169
This commit is contained in:
@@ -27,7 +27,6 @@ const decode = (s: string) => atob(s);
|
|||||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||||
const MANUAL_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
|
||||||
const CALLBACK_HOST = "127.0.0.1";
|
const CALLBACK_HOST = "127.0.0.1";
|
||||||
const CALLBACK_PORT = 53692;
|
const CALLBACK_PORT = 53692;
|
||||||
const CALLBACK_PATH = "/callback";
|
const CALLBACK_PATH = "/callback";
|
||||||
@@ -302,7 +301,6 @@ export async function loginAnthropic(options: {
|
|||||||
}
|
}
|
||||||
code = parsed.code;
|
code = parsed.code;
|
||||||
state = parsed.state ?? verifier;
|
state = parsed.state ?? verifier;
|
||||||
redirectUriForExchange = MANUAL_REDIRECT_URI;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@@ -317,7 +315,6 @@ export async function loginAnthropic(options: {
|
|||||||
}
|
}
|
||||||
code = parsed.code;
|
code = parsed.code;
|
||||||
state = parsed.state ?? verifier;
|
state = parsed.state ?? verifier;
|
||||||
redirectUriForExchange = MANUAL_REDIRECT_URI;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -332,7 +329,7 @@ export async function loginAnthropic(options: {
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
const input = await options.onPrompt({
|
const input = await options.onPrompt({
|
||||||
message: "Paste the authorization code or full redirect URL:",
|
message: "Paste the authorization code or full redirect URL:",
|
||||||
placeholder: MANUAL_REDIRECT_URI,
|
placeholder: REDIRECT_URI,
|
||||||
});
|
});
|
||||||
const parsed = parseAuthorizationInput(input);
|
const parsed = parseAuthorizationInput(input);
|
||||||
if (parsed.state && parsed.state !== verifier) {
|
if (parsed.state && parsed.state !== verifier) {
|
||||||
@@ -340,7 +337,6 @@ export async function loginAnthropic(options: {
|
|||||||
}
|
}
|
||||||
code = parsed.code;
|
code = parsed.code;
|
||||||
state = parsed.state ?? verifier;
|
state = parsed.state ?? verifier;
|
||||||
redirectUriForExchange = MANUAL_REDIRECT_URI;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
@@ -368,7 +364,6 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
|
|||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: SCOPES,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
|
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
|
||||||
|
|||||||
99
packages/ai/test/anthropic-oauth.test.ts
Normal file
99
packages/ai/test/anthropic-oauth.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.js";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrl(input: unknown): string {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
if (input instanceof URL) {
|
||||||
|
return input.toString();
|
||||||
|
}
|
||||||
|
if (input instanceof Request) {
|
||||||
|
return input.url;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJsonBody(init?: RequestInit): Record<string, string> {
|
||||||
|
if (typeof init?.body !== "string") {
|
||||||
|
throw new Error(`Expected string request body, got ${typeof init?.body}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(init.body) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.sequential("Anthropic OAuth", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the localhost redirect_uri for manual callback login", async () => {
|
||||||
|
let authUrl = "";
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||||
|
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
const body = getJsonBody(init);
|
||||||
|
expect(body.grant_type).toBe("authorization_code");
|
||||||
|
expect(body.code).toBe("manual-code");
|
||||||
|
expect(body.redirect_uri).toBe("http://localhost:53692/callback");
|
||||||
|
return jsonResponse({
|
||||||
|
access_token: "access-token",
|
||||||
|
refresh_token: "refresh-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const credentials = await loginAnthropic({
|
||||||
|
onAuth: (info) => {
|
||||||
|
authUrl = info.url;
|
||||||
|
},
|
||||||
|
onPrompt: async () => "",
|
||||||
|
onManualCodeInput: async () => {
|
||||||
|
const url = new URL(authUrl);
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
const redirectUri = url.searchParams.get("redirect_uri");
|
||||||
|
if (!state || !redirectUri) {
|
||||||
|
throw new Error("Missing OAuth state or redirect_uri in auth URL");
|
||||||
|
}
|
||||||
|
return `${redirectUri}?code=manual-code&state=${state}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(credentials.access).toBe("access-token");
|
||||||
|
expect(credentials.refresh).toBe("refresh-token");
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits scope from refresh token requests", async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||||
|
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
const body = getJsonBody(init);
|
||||||
|
expect(body.grant_type).toBe("refresh_token");
|
||||||
|
expect(body.client_id).toBeTruthy();
|
||||||
|
expect(body.refresh_token).toBe("refresh-token");
|
||||||
|
expect(body).not.toHaveProperty("scope");
|
||||||
|
return jsonResponse({
|
||||||
|
access_token: "new-access-token",
|
||||||
|
refresh_token: "new-refresh-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const credentials = await refreshAnthropicToken("refresh-token");
|
||||||
|
|
||||||
|
expect(credentials.access).toBe("new-access-token");
|
||||||
|
expect(credentials.refresh).toBe("new-refresh-token");
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user