fix(FN-724): implement OAuth token refresh for expired Claude tokens
- Add token expiry detection with 60-second buffer before API calls - Implement refresh_token grant flow against Anthropic OAuth endpoint - Cache refreshed access tokens in-memory only (never written to disk) - Retry token refresh on 401/403 responses before failing - Add comprehensive tests for token refresh, expiry, and error scenarios
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
_parseClaudeResetText,
|
||||
withTimeout,
|
||||
CLAUDE_FETCH_TIMEOUT_MS,
|
||||
_clearRefreshedToken,
|
||||
} from "./usage.js";
|
||||
|
||||
// Mock the https module
|
||||
@@ -40,6 +41,7 @@ vi.mock("node-pty", () => {
|
||||
describe("usage", () => {
|
||||
beforeEach(() => {
|
||||
clearUsageCache();
|
||||
_clearRefreshedToken();
|
||||
mockRequest.mockClear();
|
||||
mockReadFileSync.mockClear();
|
||||
mockExecFileSync.mockClear();
|
||||
@@ -400,7 +402,7 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
expect(claude!.error).toContain("Claude token expired");
|
||||
});
|
||||
|
||||
it("handles 403 auth error", async () => {
|
||||
@@ -429,7 +431,7 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
expect(claude!.error).toContain("Claude token expired");
|
||||
});
|
||||
|
||||
it("does not send anthropic-beta header in requests", async () => {
|
||||
@@ -812,7 +814,7 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
expect(claude!.error).toContain("Claude token expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
@@ -882,12 +884,271 @@ describe("usage", () => {
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Auth expired");
|
||||
expect(claude!.error).toContain("Claude token expired");
|
||||
// No retries should happen for auth errors
|
||||
expect(noopSleep).not.toHaveBeenCalled();
|
||||
|
||||
_resetSleepFn();
|
||||
});
|
||||
|
||||
describe("token refresh", () => {
|
||||
it("refreshes expired token before calling usage API", async () => {
|
||||
const expiredAt = Date.now() - 60_000; // expired 1 minute ago
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "expired-token",
|
||||
expiresAt: expiredAt,
|
||||
refreshToken: "refresh-token-123",
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "max",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Track which requests are made
|
||||
const requestUrls: 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);
|
||||
|
||||
if (url.includes("oauth/token")) {
|
||||
// Token refresh succeeds
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ access_token: "new-fresh-token" })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
} else {
|
||||
// Usage API succeeds
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({
|
||||
five_hour: { utilization: 50.0, resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() },
|
||||
})));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
}
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(1);
|
||||
expect(claude.windows[0].percentUsed).toBe(50);
|
||||
// Should have called token refresh first, then usage API
|
||||
expect(requestUrls).toHaveLength(2);
|
||||
expect(requestUrls[0]).toContain("oauth/token");
|
||||
expect(requestUrls[1]).toContain("oauth/usage");
|
||||
});
|
||||
|
||||
it("returns actionable error when refresh fails for expired token", async () => {
|
||||
const expiredAt = Date.now() - 60_000;
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "expired-token",
|
||||
expiresAt: expiredAt,
|
||||
refreshToken: "bad-refresh-token",
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((_options: any, callback: any) => {
|
||||
const mockRes = {
|
||||
statusCode: 400,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error":"invalid_grant"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Claude token expired");
|
||||
expect(claude.error).toContain("re-login");
|
||||
});
|
||||
|
||||
it("returns actionable error when no refresh token and token is expired", async () => {
|
||||
const expiredAt = Date.now() - 60_000;
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "expired-token",
|
||||
expiresAt: expiredAt,
|
||||
// No refreshToken
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("error");
|
||||
expect(claude.error).toContain("Claude token expired");
|
||||
});
|
||||
|
||||
it("does not refresh when token is not expired", async () => {
|
||||
const expiresAt = Date.now() + 3600_000; // expires in 1 hour
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "valid-token",
|
||||
expiresAt,
|
||||
refreshToken: "refresh-token",
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setupClaudeApiResponse({
|
||||
five_hour: { utilization: 10.0 },
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
// Only the usage API call should have been made, no refresh
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("attempts refresh on 401 response as recovery", async () => {
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "stale-token",
|
||||
// No expiresAt — so won't pre-refresh
|
||||
refreshToken: "refresh-token-456",
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
callCount++;
|
||||
const url = `https://${options.hostname}${options.path}`;
|
||||
|
||||
if (url.includes("oauth/token")) {
|
||||
// Refresh succeeds
|
||||
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 if (callCount === 1) {
|
||||
// First usage call returns 401
|
||||
const mockRes = {
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from('{"error":"unauthorized"}'));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
} else {
|
||||
// Second usage call with refreshed token succeeds
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({
|
||||
five_hour: { utilization: 30.0 },
|
||||
})));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
}
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.windows).toHaveLength(1);
|
||||
expect(claude.windows[0].percentUsed).toBe(30);
|
||||
});
|
||||
|
||||
it("treats token as expired when within 60s buffer of expiresAt", async () => {
|
||||
const expiresAt = Date.now() + 30_000; // expires in 30s (within buffer)
|
||||
setupClaudeMocks({
|
||||
credFileContent: {
|
||||
claudeAiOauth: {
|
||||
accessToken: "almost-expired-token",
|
||||
expiresAt,
|
||||
refreshToken: "refresh-token",
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requestUrls: 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);
|
||||
|
||||
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: "fresh-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: 5.0 } })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
}
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
// Token should have been refreshed (within 60s buffer)
|
||||
expect(requestUrls[0]).toContain("oauth/token");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex provider", () => {
|
||||
|
||||
@@ -251,6 +251,67 @@ const CLAUDE_MAX_RETRIES = 3;
|
||||
/** Initial retry delay in ms (doubles each attempt) */
|
||||
const CLAUDE_INITIAL_RETRY_MS = 1000;
|
||||
|
||||
/**
|
||||
* In-memory cache for refreshed OAuth access tokens.
|
||||
* Never written back to disk/keychain — only lives for the process lifetime.
|
||||
*/
|
||||
let refreshedAccessToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Anthropic OAuth token refresh endpoint.
|
||||
* Used when the access token has expired but a refresh token is available.
|
||||
*/
|
||||
const ANTHROPIC_TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function isTokenExpired(expiresAt: number | undefined): boolean {
|
||||
if (expiresAt === undefined) return false; // No expiry info — assume valid
|
||||
const bufferMs = 60_000; // Treat tokens expiring within 60s as expired
|
||||
return Date.now() >= expiresAt - bufferMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
async function refreshClaudeAccessToken(refreshToken: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await httpsRequest(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
timeout: 10_000, // 10s timeout for refresh
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(res.body);
|
||||
const newToken = data.access_token || data.accessToken;
|
||||
if (newToken) {
|
||||
// Cache in memory only — never written back to disk/keychain
|
||||
refreshedAccessToken = newToken;
|
||||
return newToken;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the in-memory refreshed token cache (for testing) */
|
||||
export function _clearRefreshedToken(): void {
|
||||
refreshedAccessToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for the given duration. Exported for test mocking.
|
||||
*/
|
||||
@@ -661,6 +722,32 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
else usage.plan = oauthCreds.rateLimitTier;
|
||||
}
|
||||
|
||||
// ── Resolve the best available access token ─────────────────────────
|
||||
// If we have a previously refreshed token in memory, prefer it.
|
||||
// Otherwise check if the stored token is expired and attempt refresh.
|
||||
let activeToken: string = refreshedAccessToken || oauthCreds.accessToken;
|
||||
|
||||
const tokenExpired = isTokenExpired(oauthCreds.expiresAt);
|
||||
if (tokenExpired && !refreshedAccessToken) {
|
||||
// Token is expired — attempt refresh before calling the usage API
|
||||
if (oauthCreds.refreshToken) {
|
||||
const newToken = await refreshClaudeAccessToken(oauthCreds.refreshToken);
|
||||
if (newToken) {
|
||||
activeToken = newToken;
|
||||
} else {
|
||||
// Refresh failed — return actionable error immediately
|
||||
usage.status = "error";
|
||||
usage.error = "Claude token expired — run `claude` to re-login";
|
||||
return usage;
|
||||
}
|
||||
} else {
|
||||
// No refresh token available
|
||||
usage.status = "error";
|
||||
usage.error = "Claude token expired — run `claude` to re-login";
|
||||
return usage;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch usage via direct API call with retry for 429 ─────────────
|
||||
try {
|
||||
let res: { status: number; headers: Record<string, string>; body: string } | undefined;
|
||||
@@ -670,16 +757,24 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
|
||||
res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: `Bearer ${oauthCreds.accessToken}`,
|
||||
authorization: `Bearer ${activeToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
lastStatus = res.status;
|
||||
|
||||
// Auth errors are not transient — fail immediately
|
||||
// Auth errors — attempt token refresh once before giving up
|
||||
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);
|
||||
if (newToken) {
|
||||
activeToken = newToken;
|
||||
continue; // Retry with refreshed token
|
||||
}
|
||||
}
|
||||
usage.status = "error";
|
||||
usage.error = "Auth expired — run 'claude' to re-login";
|
||||
usage.error = "Claude token expired — run `claude` to re-login";
|
||||
return usage;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user