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");
|
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 = {
|
const mockResponse = {
|
||||||
five_hour: { utilization: 10.0 },
|
five_hour: { utilization: 10.0 },
|
||||||
};
|
};
|
||||||
@@ -491,8 +491,11 @@ describe("usage", () => {
|
|||||||
|
|
||||||
await fetchAllProviderUsage();
|
await fetchAllProviderUsage();
|
||||||
|
|
||||||
// Verify no anthropic-beta header is sent
|
// Anthropic now requires the `anthropic-beta: oauth-2025-04-20` header on
|
||||||
expect(capturedHeaders).not.toHaveProperty("anthropic-beta");
|
// /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 () => {
|
it("retries on 429 and succeeds after transient rate limit", async () => {
|
||||||
@@ -1206,19 +1209,20 @@ describe("usage", () => {
|
|||||||
|
|
||||||
await fetchAllProviderUsage();
|
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];
|
const refreshOpts = capturedOptions[0];
|
||||||
expect(refreshOpts.hostname).toBe("platform.claude.com");
|
expect(refreshOpts.hostname).toBe("platform.claude.com");
|
||||||
expect(refreshOpts.path).toBe("/v1/oauth/token");
|
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);
|
expect(capturedBodies.length).toBeGreaterThanOrEqual(1);
|
||||||
const body = capturedBodies[0];
|
const body = JSON.parse(capturedBodies[0]);
|
||||||
const params = new URLSearchParams(body);
|
expect(body.grant_type).toBe("refresh_token");
|
||||||
expect(params.get("grant_type")).toBe("refresh_token");
|
expect(body.refresh_token).toBe("refresh-token-456");
|
||||||
expect(params.get("refresh_token")).toBe("refresh-token-456");
|
expect(body.client_id).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
||||||
expect(params.get("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 () => {
|
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";
|
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
|
* 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.
|
* 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.
|
* Attempt to refresh the OAuth access token using the refresh token.
|
||||||
* Returns the new access token on success, or null on failure.
|
* Returns the new access token on success, or null on failure.
|
||||||
* The refreshed token is cached in memory only (not written to disk/keychain).
|
* 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 {
|
try {
|
||||||
const body = new URLSearchParams({
|
const payload: Record<string, string> = {
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
||||||
}).toString();
|
};
|
||||||
|
if (scopes && scopes.length > 0) {
|
||||||
|
payload.scope = scopes.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
|
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
headers: {
|
||||||
body,
|
"content-type": "application/json",
|
||||||
|
"user-agent": CLAUDE_USAGE_USER_AGENT,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
timeout: 10_000, // 10s timeout for refresh
|
timeout: 10_000, // 10s timeout for refresh
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -870,7 +898,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|||||||
if (tokenExpired && !refreshedAccessToken) {
|
if (tokenExpired && !refreshedAccessToken) {
|
||||||
// Token is expired — attempt refresh before calling the usage API
|
// Token is expired — attempt refresh before calling the usage API
|
||||||
if (oauthCreds.refreshToken) {
|
if (oauthCreds.refreshToken) {
|
||||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
|
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
activeToken = newToken;
|
activeToken = newToken;
|
||||||
} else {
|
} else {
|
||||||
@@ -893,6 +921,8 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${activeToken}`,
|
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 (res.status === 401 || res.status === 403) {
|
||||||
if (oauthCreds.refreshToken && activeToken !== refreshedAccessToken) {
|
if (oauthCreds.refreshToken && activeToken !== refreshedAccessToken) {
|
||||||
// Try refreshing the token as a recovery path
|
// Try refreshing the token as a recovery path
|
||||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
|
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken, scopes);
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
activeToken = newToken;
|
activeToken = newToken;
|
||||||
continue; // Retry with refreshed token
|
continue; // Retry with refreshed token
|
||||||
|
|||||||
Reference in New Issue
Block a user