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,
|
_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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user