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:
gsxdsm
2026-04-02 19:11:07 -07:00
parent 37df00d4d7
commit 30f6413e15
2 changed files with 89 additions and 8 deletions

View File

@@ -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({

View File

@@ -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
});