fix(cli): refresh expired tokens for documentation commands (#3003)

* fix(cli): refresh expired tokens for documentation commands

- Reuse getValidAccessToken in library and docs commands
- Avoid anonymous fallback when OAuth access tokens expire

* fix(cli): refresh expired tokens in skills suggest and generate

Route the remaining hand-rolled loadTokens/isTokenExpired checks through
getValidAccessToken so an expired token refreshes instead of silently
falling back to anonymous (skills suggest) or forcing a full re-login
(generate). Return undefined instead of null to match the optional
accessToken parameter on the API surface.

* fix(cli): preserve refresh_token and pin the auth wiring

RFC 6749 §6 permits a refresh response that omits refresh_token, in which
case the client keeps the one it holds. getValidAccessToken wrote the
response verbatim, dropping the stored token and silently logging the user
out at the next expiry. This PR widened that path from 2 commands to 6, so
fix it here.

Add a wiring test asserting each command passes a refreshed token to its
API call, and an eslint rule blocking loadTokens/isTokenExpired imports in
src/commands so the inline check cannot come back.

---------

Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
This commit is contained in:
Illustar0
2026-08-11 17:03:51 +08:00
committed by GitHub
parent 895c5c3997
commit 91dea9f192
9 changed files with 241 additions and 38 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"ctx7": patch
---
Fix `ctx7 library`, `ctx7 docs` and `ctx7 skills suggest` silently falling back to anonymous requests when the stored OAuth token expires, which surfaced misleading quota errors for authenticated users. `ctx7 generate` no longer forces a full interactive re-login when the token can be refreshed instead. All four commands now go through `getValidAccessToken()`, which refreshes expired credentials.
A successful refresh also keeps the stored `refresh_token` when the server omits one from the response, as permitted by RFC 6749 §6. Previously the response was written verbatim, so the refresh token was dropped and the user was silently logged out at the next expiry.
+19
View File
@@ -43,5 +43,24 @@ export default defineConfig(
// Prettier integration
"prettier/prettier": "error",
},
},
{
// Commands must not hand-roll the "load tokens, check expiry" dance: it
// skips the refresh and silently degrades to an anonymous request.
files: ["src/commands/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "../utils/auth.js",
importNames: ["loadTokens", "isTokenExpired"],
message: "Use getValidAccessToken() so an expired token refreshes.",
},
],
},
],
},
}
);
@@ -90,7 +90,7 @@ describe("login command", () => {
});
test("calls process.exit(1) when login fails", async () => {
mockGetValidAccessToken.mockResolvedValue(null);
mockGetValidAccessToken.mockResolvedValue(undefined);
mockClearTokens.mockReturnValue(false);
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
@@ -121,7 +121,7 @@ describe("logout command", () => {
describe("whoami command", () => {
test("shows 'Not logged in' when no valid token", async () => {
mockGetValidAccessToken.mockResolvedValue(null);
mockGetValidAccessToken.mockResolvedValue(undefined);
await runCommand("whoami");
expect(logOutput.some((l) => l.includes("Not logged in"))).toBe(true);
});
@@ -163,7 +163,7 @@ describe("whoami command", () => {
});
test("tracks whoami event", async () => {
mockGetValidAccessToken.mockResolvedValue(null);
mockGetValidAccessToken.mockResolvedValue(undefined);
await runCommand("whoami");
expect(trackEvent).toHaveBeenCalledWith("command", { name: "whoami" });
});
+66 -8
View File
@@ -221,9 +221,9 @@ describe("isTokenExpired", () => {
});
describe("getValidAccessToken", () => {
test("returns null when no tokens stored", async () => {
test("returns undefined when no tokens stored", async () => {
mfs.existsSync.mockReturnValue(false);
expect(await getValidAccessToken()).toBeNull();
expect(await getValidAccessToken()).toBeUndefined();
});
test("returns access_token when not expired", async () => {
@@ -237,7 +237,7 @@ describe("getValidAccessToken", () => {
expect(await getValidAccessToken()).toBe("valid-tok");
});
test("returns null when expired and no refresh_token", async () => {
test("returns undefined when expired and no refresh_token", async () => {
const tokens: TokenData = {
access_token: "expired-tok",
token_type: "bearer",
@@ -245,7 +245,7 @@ describe("getValidAccessToken", () => {
};
mfs.existsSync.mockReturnValue(true);
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
expect(await getValidAccessToken()).toBeNull();
expect(await getValidAccessToken()).toBeUndefined();
});
test("refreshes token when expired and refresh_token exists", async () => {
@@ -285,7 +285,65 @@ describe("getValidAccessToken", () => {
expect(mfs.writeFileSync).toHaveBeenCalled();
});
test("returns null when refresh fails", async () => {
// RFC 6749 §6: dropping the stored refresh_token here would log the user out
// at the next expiry, with no error to explain why.
test("keeps the stored refresh_token when the refresh response omits one", async () => {
const tokens: TokenData = {
access_token: "expired-tok",
token_type: "bearer",
expires_at: Date.now() - 1000,
refresh_token: "refresh-tok",
};
mfs.existsSync.mockReturnValue(true);
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({ access_token: "new-tok", token_type: "bearer", expires_in: 3600 }),
})
);
expect(await getValidAccessToken()).toBe("new-tok");
const written = JSON.parse(mfs.writeFileSync.mock.calls[0][1] as string);
expect(written.refresh_token).toBe("refresh-tok");
expect(written.access_token).toBe("new-tok");
});
test("prefers a rotated refresh_token over the stored one", async () => {
const tokens: TokenData = {
access_token: "expired-tok",
token_type: "bearer",
expires_at: Date.now() - 1000,
refresh_token: "refresh-tok",
};
mfs.existsSync.mockReturnValue(true);
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: "new-tok",
token_type: "bearer",
expires_in: 3600,
refresh_token: "rotated-tok",
}),
})
);
expect(await getValidAccessToken()).toBe("new-tok");
const written = JSON.parse(mfs.writeFileSync.mock.calls[0][1] as string);
expect(written.refresh_token).toBe("rotated-tok");
});
test("returns undefined when refresh fails", async () => {
const tokens: TokenData = {
access_token: "expired-tok",
token_type: "bearer",
@@ -305,12 +363,12 @@ describe("getValidAccessToken", () => {
})
);
expect(await getValidAccessToken()).toBeNull();
expect(await getValidAccessToken()).toBeUndefined();
});
// An expired refresh token is indistinguishable from being logged out, so the
// caller reports "not logged in" rather than surfacing a network error here.
test("returns null when the refresh connection fails", async () => {
test("returns undefined when the refresh connection fails", async () => {
const tokens: TokenData = {
access_token: "expired-tok",
token_type: "bearer",
@@ -329,7 +387,7 @@ describe("getValidAccessToken", () => {
)
);
expect(await getValidAccessToken()).toBeNull();
expect(await getValidAccessToken()).toBeUndefined();
});
});
@@ -0,0 +1,126 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
// Every command that talks to the API must obtain its token from
// getValidAccessToken(), so an expired one refreshes instead of silently
// degrading to an anonymous request. Unit tests on the helper cannot catch a
// command that never calls it, which is how three commands drifted for months.
const mockGetValidAccessToken = vi.fn();
vi.mock("../utils/auth.js", () => ({
getValidAccessToken: (...args: unknown[]) => mockGetValidAccessToken(...args),
}));
const mockResolveLibrary = vi.fn();
const mockGetLibraryContext = vi.fn();
const mockSuggestSkills = vi.fn();
vi.mock("../utils/api.js", () => ({
resolveLibrary: (...args: unknown[]) => mockResolveLibrary(...args),
getLibraryContext: (...args: unknown[]) => mockGetLibraryContext(...args),
suggestSkills: (...args: unknown[]) => mockSuggestSkills(...args),
getBaseUrl: () => "https://test.context7.com",
listProjectSkills: vi.fn(),
searchSkills: vi.fn(),
downloadSkill: vi.fn(),
getSkill: vi.fn(),
getSkillQuota: vi.fn(),
getSkillQuestions: vi.fn(),
generateSkillStructured: vi.fn(),
searchLibraries: vi.fn(),
}));
vi.mock("../utils/tracking.js", () => ({ trackEvent: vi.fn() }));
vi.mock("../utils/deps.js", () => ({
detectProjectDependencies: () => ["react"],
}));
const mockSpinner = {
start: vi.fn().mockReturnThis(),
stop: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
warn: vi.fn().mockReturnThis(),
text: "",
};
vi.mock("ora", () => ({ default: () => mockSpinner }));
import { registerDocsCommands } from "../commands/docs.js";
import { registerSkillCommands } from "../commands/skill.js";
const REFRESHED = "refreshed-token";
beforeEach(() => {
vi.clearAllMocks();
mockGetValidAccessToken.mockResolvedValue(REFRESHED);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
async function run(register: (p: Command) => void, ...args: string[]): Promise<void> {
const program = new Command();
program.exitOverride();
register(program);
await program.parseAsync(["node", "test", ...args]);
}
describe("commands pass a refreshed token to the API", () => {
test("ctx7 library", async () => {
mockResolveLibrary.mockResolvedValue({ results: [{ id: "/a/b", title: "B" }] });
await run(registerDocsCommands, "library", "react");
expect(mockGetValidAccessToken).toHaveBeenCalled();
expect(mockResolveLibrary).toHaveBeenCalledWith("react", undefined, REFRESHED);
});
test("ctx7 docs", async () => {
mockGetLibraryContext.mockResolvedValue("docs body");
await run(registerDocsCommands, "docs", "/a/b", "how does it work");
expect(mockGetValidAccessToken).toHaveBeenCalled();
expect(mockGetLibraryContext).toHaveBeenCalledWith(
"/a/b",
"how does it work",
{ type: "txt" },
REFRESHED
);
});
test("ctx7 skills suggest", async () => {
mockSuggestSkills.mockResolvedValue({ skills: [] });
await run(registerSkillCommands, "skills", "suggest");
expect(mockGetValidAccessToken).toHaveBeenCalled();
expect(mockSuggestSkills).toHaveBeenCalledWith(["react"], REFRESHED);
});
});
describe("commands stay anonymous when no token is available", () => {
beforeEach(() => {
mockGetValidAccessToken.mockResolvedValue(undefined);
});
test("ctx7 library sends no token", async () => {
mockResolveLibrary.mockResolvedValue({ results: [] });
await run(registerDocsCommands, "library", "react");
expect(mockResolveLibrary).toHaveBeenCalledWith("react", undefined, undefined);
});
test("ctx7 docs sends no token", async () => {
mockGetLibraryContext.mockResolvedValue("docs body");
await run(registerDocsCommands, "docs", "/a/b", "q");
expect(mockGetLibraryContext).toHaveBeenCalledWith("/a/b", "q", { type: "txt" }, undefined);
});
});
+3 -10
View File
@@ -6,7 +6,7 @@ import { resolveLibrary, getLibraryContext } from "../utils/api.js";
import { recoverLibraryId } from "../utils/library-id.js";
import { log } from "../utils/logger.js";
import { trackEvent } from "../utils/tracking.js";
import { loadTokens, isTokenExpired } from "../utils/auth.js";
import { getValidAccessToken } from "../utils/auth.js";
import type { LibrarySearchResult, ContextResponse } from "../types.js";
const isTTY = process.stdout.isTTY;
@@ -18,12 +18,6 @@ function getReputationLabel(score: number | undefined): "High" | "Medium" | "Low
return "Low";
}
function getAccessToken(): string | undefined {
const tokens = loadTokens();
if (!tokens || isTokenExpired(tokens)) return undefined;
return tokens.access_token;
}
function formatLibraryResult(lib: LibrarySearchResult, index: number): string {
const lines: string[] = [];
lines.push(`${pc.dim(`${index + 1}.`)} ${pc.bold(`Title: ${lib.title}`)}`);
@@ -57,7 +51,7 @@ async function resolveCommand(
trackEvent("command", { name: "library" });
const spinner = isTTY ? ora(`Searching for "${library}"...`).start() : null;
const accessToken = getAccessToken();
const accessToken = await getValidAccessToken();
let data;
try {
@@ -137,9 +131,8 @@ async function queryCommand(
return;
}
const accessToken = getAccessToken();
const spinner = isTTY ? ora(`Fetching docs for "${libraryId}"...`).start() : null;
const accessToken = await getValidAccessToken();
const outputType = options.json ? "json" : "txt";
let result;
+6 -8
View File
@@ -13,7 +13,7 @@ import {
generateSkillStructured,
getSkillQuota,
} from "../utils/api.js";
import { loadTokens, isTokenExpired } from "../utils/auth.js";
import { getValidAccessToken } from "../utils/auth.js";
import { performLogin } from "./auth.js";
import { log } from "../utils/logger.js";
import { promptForInstallTargets, getTargetDirs } from "../utils/ide.js";
@@ -60,18 +60,16 @@ async function generateCommand(options: GenerateOptions): Promise<void> {
trackEvent("command", { name: "generate" });
log.blank();
let accessToken: string | null = null;
const tokens = loadTokens();
if (tokens && !isTokenExpired(tokens)) {
accessToken = tokens.access_token;
} else {
let accessToken = await getValidAccessToken();
if (!accessToken) {
log.info("Authentication required. Logging in...");
log.blank();
accessToken = await performLogin();
if (!accessToken) {
const token = await performLogin();
if (!token) {
log.error("Login failed. Please try again.");
return;
}
accessToken = token;
log.blank();
}
+2 -3
View File
@@ -55,7 +55,7 @@ import {
} from "../types.js";
import { homedir } from "os";
import { detectProjectDependencies } from "../utils/deps.js";
import { loadTokens, isTokenExpired } from "../utils/auth.js";
import { getValidAccessToken } from "../utils/auth.js";
const SKILL_HUB_DEPRECATION_WARNING =
"Warning: Skill commands are deprecated and will stop working in the next major release.";
@@ -843,8 +843,7 @@ async function suggestCommand(options: SuggestOptions): Promise<void> {
// Step 2: Single API call to backend
const searchSpinner = ora("Finding matching skills...").start();
const tokens = loadTokens();
const accessToken = tokens && !isTokenExpired(tokens) ? tokens.access_token : undefined;
const accessToken = await getValidAccessToken();
let data;
try {
+9 -6
View File
@@ -97,29 +97,32 @@ async function refreshAccessToken(refreshToken: string): Promise<TokenData> {
}
/**
* Returns a valid access token, refreshing if expired. Returns null if no
* Returns a valid access token, refreshing if expired. Returns undefined if no
* tokens are stored or refresh fails. Pre-0.5 installs may have OAuth tokens
* with a `refresh_token`; new installs hold long-lived API keys that never
* expire and skip the refresh path entirely.
*/
export async function getValidAccessToken(): Promise<string | null> {
export async function getValidAccessToken(): Promise<string | undefined> {
const tokens = loadTokens();
if (!tokens) return null;
if (!tokens) return undefined;
if (!isTokenExpired(tokens)) {
return tokens.access_token;
}
if (!tokens.refresh_token) {
return null;
return undefined;
}
try {
const newTokens = await refreshAccessToken(tokens.refresh_token);
saveTokens(newTokens);
// RFC 6749 §6: the response MAY omit refresh_token, and the client then
// keeps the one it already holds. Writing the response verbatim would
// drop it and log the user out at the next expiry.
saveTokens({ refresh_token: tokens.refresh_token, ...newTokens });
return newTokens.access_token;
} catch {
return null;
return undefined;
}
}