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:
gsxdsm
2026-04-02 18:54:44 -07:00
parent 39c4ca15ac
commit 971c7be983
2 changed files with 363 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ import {
_parseClaudeResetText, _parseClaudeResetText,
withTimeout, withTimeout,
CLAUDE_FETCH_TIMEOUT_MS, CLAUDE_FETCH_TIMEOUT_MS,
_clearRefreshedToken,
} from "./usage.js"; } from "./usage.js";
// Mock the https module // Mock the https module
@@ -40,6 +41,7 @@ vi.mock("node-pty", () => {
describe("usage", () => { describe("usage", () => {
beforeEach(() => { beforeEach(() => {
clearUsageCache(); clearUsageCache();
_clearRefreshedToken();
mockRequest.mockClear(); mockRequest.mockClear();
mockReadFileSync.mockClear(); mockReadFileSync.mockClear();
mockExecFileSync.mockClear(); mockExecFileSync.mockClear();
@@ -400,7 +402,7 @@ describe("usage", () => {
const claude = providers.find((p) => p.name === "Claude")!; const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("error"); expect(claude.status).toBe("error");
expect(claude.error).toContain("Auth expired"); expect(claude!.error).toContain("Claude token expired");
}); });
it("handles 403 auth error", async () => { it("handles 403 auth error", async () => {
@@ -429,7 +431,7 @@ describe("usage", () => {
const claude = providers.find((p) => p.name === "Claude")!; const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("error"); 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 () => { it("does not send anthropic-beta header in requests", async () => {
@@ -812,7 +814,7 @@ describe("usage", () => {
const claude = providers.find((p) => p.name === "Claude")!; const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("error"); 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 // No retries should happen for auth errors
expect(noopSleep).not.toHaveBeenCalled(); expect(noopSleep).not.toHaveBeenCalled();
@@ -882,12 +884,271 @@ describe("usage", () => {
const claude = providers.find((p) => p.name === "Claude")!; const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("error"); 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 // No retries should happen for auth errors
expect(noopSleep).not.toHaveBeenCalled(); expect(noopSleep).not.toHaveBeenCalled();
_resetSleepFn(); _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", () => { describe("Codex provider", () => {

View File

@@ -251,6 +251,67 @@ const CLAUDE_MAX_RETRIES = 3;
/** Initial retry delay in ms (doubles each attempt) */ /** Initial retry delay in ms (doubles each attempt) */
const CLAUDE_INITIAL_RETRY_MS = 1000; 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. * Sleep for the given duration. Exported for test mocking.
*/ */
@@ -661,6 +722,32 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
else usage.plan = oauthCreds.rateLimitTier; 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 ───────────── // ── Fetch usage via direct API call with retry for 429 ─────────────
try { try {
let res: { status: number; headers: Record<string, string>; body: string } | undefined; 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", { res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET", method: "GET",
headers: { headers: {
authorization: `Bearer ${oauthCreds.accessToken}`, authorization: `Bearer ${activeToken}`,
}, },
}); });
lastStatus = res.status; 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 (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.status = "error";
usage.error = "Auth expired — run 'claude' to re-login"; usage.error = "Claude token expired — run `claude` to re-login";
return usage; return usage;
} }