fix(FN-728): fix token refresh endpoint, content-type, and client_id
- Fix refresh token request to use correct endpoint URL - Set proper content-type header for token refresh requests - Include client_id in refresh token payload - Add tests verifying correct endpoint, content-type, and client_id in refresh flow
This commit is contained in:
@@ -908,10 +908,13 @@ describe("usage", () => {
|
||||
|
||||
// Track which requests are made
|
||||
const requestUrls: string[] = [];
|
||||
const capturedOptions: any[] = [];
|
||||
const capturedBodies: string[] = [];
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
const url = `https://${options.hostname}${options.path}`;
|
||||
requestUrls.push(url);
|
||||
capturedOptions.push(options);
|
||||
|
||||
if (url.includes("oauth/token")) {
|
||||
// Token refresh succeeds
|
||||
@@ -953,6 +956,74 @@ describe("usage", () => {
|
||||
expect(requestUrls[1]).toContain("oauth/usage");
|
||||
});
|
||||
|
||||
it("sends refresh request to platform.claude.com with correct content-type and client_id", async () => {
|
||||
const expiredAt = Date.now() - 60_000;
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "expired-token",
|
||||
expiresAt: expiredAt,
|
||||
refreshToken: "refresh-token-456",
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "max",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const capturedOptions: any[] = [];
|
||||
const capturedBodies: string[] = [];
|
||||
const mockReq = {
|
||||
on: vi.fn(),
|
||||
write: vi.fn((data: string) => capturedBodies.push(data)),
|
||||
end: vi.fn(),
|
||||
};
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
capturedOptions.push({ ...options });
|
||||
const url = `https://${options.hostname}${options.path}`;
|
||||
|
||||
if (url.includes("oauth/token")) {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ access_token: "refreshed-token" })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
} else {
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({
|
||||
five_hour: { utilization: 10.0, resets_at: new Date(Date.now() + 3600000).toISOString() },
|
||||
})));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
}
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Verify the refresh request (first call)
|
||||
const refreshOpts = capturedOptions[0];
|
||||
expect(refreshOpts.hostname).toBe("platform.claude.com");
|
||||
expect(refreshOpts.path).toBe("/v1/oauth/token");
|
||||
expect(refreshOpts.headers["content-type"]).toBe("application/x-www-form-urlencoded");
|
||||
|
||||
// Verify body contains required parameters
|
||||
expect(capturedBodies.length).toBeGreaterThanOrEqual(1);
|
||||
const body = capturedBodies[0];
|
||||
const params = new URLSearchParams(body);
|
||||
expect(params.get("grant_type")).toBe("refresh_token");
|
||||
expect(params.get("refresh_token")).toBe("refresh-token-456");
|
||||
expect(params.get("client_id")).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
||||
});
|
||||
|
||||
it("returns actionable error when refresh fails for expired token", async () => {
|
||||
const expiredAt = Date.now() - 60_000;
|
||||
setupClaudeMocks({
|
||||
|
||||
@@ -258,10 +258,17 @@ const CLAUDE_INITIAL_RETRY_MS = 1000;
|
||||
let refreshedAccessToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Anthropic OAuth token refresh endpoint.
|
||||
* Used when the access token has expired but a refresh token is available.
|
||||
* Anthropic OAuth token refresh endpoint on the Claude platform.
|
||||
* The OAuth token endpoint lives on platform.claude.com (not console.anthropic.com)
|
||||
* per the Anthropic OAuth 2.0 specification.
|
||||
*/
|
||||
const ANTHROPIC_TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token";
|
||||
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
|
||||
|
||||
/**
|
||||
* Public OAuth client ID for the Claude CLI / first-party OAuth flow.
|
||||
* Required as `client_id` in token refresh requests per the OAuth 2.0 spec.
|
||||
*/
|
||||
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
/**
|
||||
* Check whether an OAuth access token is expired using the `expiresAt` timestamp
|
||||
@@ -280,13 +287,16 @@ function isTokenExpired(expiresAt: number | undefined): boolean {
|
||||
*/
|
||||
async function refreshClaudeAccessToken(refreshToken: string): Promise<string | null> {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
}).toString();
|
||||
|
||||
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
timeout: 10_000, // 10s timeout for refresh
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user