fix(FN-000): restore Claude usage dashboard after Anthropic OAuth changes
Anthropic now requires `anthropic-beta: oauth-2025-04-20` on /api/oauth/usage for OAuth-scoped tokens; without it the endpoint returns 401 "OAuth authentication is currently not supported" and the dashboard falls back to a PTY-based CLI parser that times out at 75s. Mirrors the header the Claude CLI (2.1.114) sends from `claude /usage`, and also aligns the refresh call with the CLI's JSON body + `scope` field so token refresh keeps working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -446,7 +446,7 @@ describe("usage", () => {
|
||||
expect(claude.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not send anthropic-beta header in requests", async () => {
|
||||
it("sends anthropic-beta=oauth-2025-04-20 header so the OAuth usage endpoint authorizes the request", async () => {
|
||||
const mockResponse = {
|
||||
five_hour: { utilization: 10.0 },
|
||||
};
|
||||
@@ -491,8 +491,11 @@ describe("usage", () => {
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Verify no anthropic-beta header is sent
|
||||
expect(capturedHeaders).not.toHaveProperty("anthropic-beta");
|
||||
// Anthropic now requires the `anthropic-beta: oauth-2025-04-20` header on
|
||||
// /api/oauth/usage for OAuth-scoped tokens; without it the endpoint
|
||||
// replies with 401 "OAuth authentication is currently not supported".
|
||||
// The value mirrors what the Claude CLI sends from `claude /usage`.
|
||||
expect(capturedHeaders["anthropic-beta"]).toBe("oauth-2025-04-20");
|
||||
});
|
||||
|
||||
it("retries on 429 and succeeds after transient rate limit", async () => {
|
||||
@@ -1206,19 +1209,20 @@ describe("usage", () => {
|
||||
|
||||
await fetchAllProviderUsage();
|
||||
|
||||
// Verify the refresh request (first call)
|
||||
// Verify the refresh request (first call). Body is JSON with a `scope`
|
||||
// field — matches what the Claude CLI sends; form-urlencoded bodies or
|
||||
// missing scope cause Anthropic to reject the refresh.
|
||||
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");
|
||||
expect(refreshOpts.headers["content-type"]).toBe("application/json");
|
||||
|
||||
// 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");
|
||||
const body = JSON.parse(capturedBodies[0]);
|
||||
expect(body.grant_type).toBe("refresh_token");
|
||||
expect(body.refresh_token).toBe("refresh-token-456");
|
||||
expect(body.client_id).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
||||
expect(body.scope).toBe("user:profile");
|
||||
});
|
||||
|
||||
it("falls back to CLI when refresh fails for expired token", async () => {
|
||||
|
||||
@@ -323,6 +323,20 @@ const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
|
||||
*/
|
||||
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
/**
|
||||
* `anthropic-beta` header value required by the Anthropic API to authorize
|
||||
* OAuth-scoped access to `/api/oauth/usage`. Without this header the endpoint
|
||||
* returns 401 "OAuth authentication is currently not supported". Value mirrors
|
||||
* what the Claude CLI (`claude /usage`) sends — bump when the CLI does.
|
||||
*/
|
||||
const ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20";
|
||||
|
||||
/**
|
||||
* User-Agent sent alongside OAuth usage requests. Matches the format used by
|
||||
* the Claude CLI so the call is recognizable to Anthropic.
|
||||
*/
|
||||
const CLAUDE_USAGE_USER_AGENT = "claude-code-fusion-dashboard";
|
||||
|
||||
/**
|
||||
* Check whether an OAuth access token is expired using the `expiresAt` timestamp
|
||||
* from the credential store. Returns true if expired or expiring within 60 seconds.
|
||||
@@ -337,19 +351,33 @@ function isTokenExpired(expiresAt: number | undefined): boolean {
|
||||
* Attempt to refresh the OAuth access token using the refresh token.
|
||||
* Returns the new access token on success, or null on failure.
|
||||
* The refreshed token is cached in memory only (not written to disk/keychain).
|
||||
*
|
||||
* Request shape mirrors what the Claude CLI sends: JSON body, includes a
|
||||
* `scope` field, and posts to platform.claude.com. Sending the body as
|
||||
* form-urlencoded or omitting `scope` causes Anthropic to respond with 4xx
|
||||
* errors (or silently rate-limit) even when the refresh token is valid.
|
||||
*/
|
||||
async function refreshClaudeAccessToken(refreshToken: string): Promise<string | null> {
|
||||
async function refreshClaudeAccessToken(
|
||||
refreshToken: string,
|
||||
scopes?: string[],
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
const payload: Record<string, string> = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
}).toString();
|
||||
};
|
||||
if (scopes && scopes.length > 0) {
|
||||
payload.scope = scopes.join(" ");
|
||||
}
|
||||
|
||||
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": CLAUDE_USAGE_USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
timeout: 10_000, // 10s timeout for refresh
|
||||
});
|
||||
|
||||
@@ -870,7 +898,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
if (tokenExpired && !refreshedAccessToken) {
|
||||
// Token is expired — attempt refresh before calling the usage API
|
||||
if (oauthCreds.refreshToken) {
|
||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
|
||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
|
||||
if (newToken) {
|
||||
activeToken = newToken;
|
||||
} else {
|
||||
@@ -893,6 +921,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${activeToken}`,
|
||||
"anthropic-beta": ANTHROPIC_OAUTH_BETA,
|
||||
"user-agent": CLAUDE_USAGE_USER_AGENT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -902,7 +932,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (oauthCreds.refreshToken && activeToken !== refreshedAccessToken) {
|
||||
// Try refreshing the token as a recovery path
|
||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
|
||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
|
||||
if (newToken) {
|
||||
activeToken = newToken;
|
||||
continue; // Retry with refreshed token
|
||||
|
||||
Reference in New Issue
Block a user