mirror of
https://github.com/upstash/context7.git
synced 2026-09-14 19:09:34 +08:00
Merge remote-tracking branch 'origin/master' into ctx7-1924-mcp-latest-version-guidance
This commit is contained in:
@@ -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.
|
||||||
@@ -43,5 +43,24 @@ export default defineConfig(
|
|||||||
// Prettier integration
|
// Prettier integration
|
||||||
"prettier/prettier": "error",
|
"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 () => {
|
test("calls process.exit(1) when login fails", async () => {
|
||||||
mockGetValidAccessToken.mockResolvedValue(null);
|
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||||
mockClearTokens.mockReturnValue(false);
|
mockClearTokens.mockReturnValue(false);
|
||||||
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
|
mockStartDeviceAuthorization.mockRejectedValue(new Error("network down"));
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ describe("logout command", () => {
|
|||||||
|
|
||||||
describe("whoami command", () => {
|
describe("whoami command", () => {
|
||||||
test("shows 'Not logged in' when no valid token", async () => {
|
test("shows 'Not logged in' when no valid token", async () => {
|
||||||
mockGetValidAccessToken.mockResolvedValue(null);
|
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||||
await runCommand("whoami");
|
await runCommand("whoami");
|
||||||
expect(logOutput.some((l) => l.includes("Not logged in"))).toBe(true);
|
expect(logOutput.some((l) => l.includes("Not logged in"))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,7 @@ describe("whoami command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tracks whoami event", async () => {
|
test("tracks whoami event", async () => {
|
||||||
mockGetValidAccessToken.mockResolvedValue(null);
|
mockGetValidAccessToken.mockResolvedValue(undefined);
|
||||||
await runCommand("whoami");
|
await runCommand("whoami");
|
||||||
expect(trackEvent).toHaveBeenCalledWith("command", { name: "whoami" });
|
expect(trackEvent).toHaveBeenCalledWith("command", { name: "whoami" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ describe("isTokenExpired", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getValidAccessToken", () => {
|
describe("getValidAccessToken", () => {
|
||||||
test("returns null when no tokens stored", async () => {
|
test("returns undefined when no tokens stored", async () => {
|
||||||
mfs.existsSync.mockReturnValue(false);
|
mfs.existsSync.mockReturnValue(false);
|
||||||
expect(await getValidAccessToken()).toBeNull();
|
expect(await getValidAccessToken()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns access_token when not expired", async () => {
|
test("returns access_token when not expired", async () => {
|
||||||
@@ -237,7 +237,7 @@ describe("getValidAccessToken", () => {
|
|||||||
expect(await getValidAccessToken()).toBe("valid-tok");
|
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 = {
|
const tokens: TokenData = {
|
||||||
access_token: "expired-tok",
|
access_token: "expired-tok",
|
||||||
token_type: "bearer",
|
token_type: "bearer",
|
||||||
@@ -245,7 +245,7 @@ describe("getValidAccessToken", () => {
|
|||||||
};
|
};
|
||||||
mfs.existsSync.mockReturnValue(true);
|
mfs.existsSync.mockReturnValue(true);
|
||||||
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
|
mfs.readFileSync.mockReturnValue(JSON.stringify(tokens));
|
||||||
expect(await getValidAccessToken()).toBeNull();
|
expect(await getValidAccessToken()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refreshes token when expired and refresh_token exists", async () => {
|
test("refreshes token when expired and refresh_token exists", async () => {
|
||||||
@@ -285,7 +285,65 @@ describe("getValidAccessToken", () => {
|
|||||||
expect(mfs.writeFileSync).toHaveBeenCalled();
|
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 = {
|
const tokens: TokenData = {
|
||||||
access_token: "expired-tok",
|
access_token: "expired-tok",
|
||||||
token_type: "bearer",
|
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
|
// An expired refresh token is indistinguishable from being logged out, so the
|
||||||
// caller reports "not logged in" rather than surfacing a network error here.
|
// 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 = {
|
const tokens: TokenData = {
|
||||||
access_token: "expired-tok",
|
access_token: "expired-tok",
|
||||||
token_type: "bearer",
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { resolveLibrary, getLibraryContext } from "../utils/api.js";
|
|||||||
import { recoverLibraryId } from "../utils/library-id.js";
|
import { recoverLibraryId } from "../utils/library-id.js";
|
||||||
import { log } from "../utils/logger.js";
|
import { log } from "../utils/logger.js";
|
||||||
import { trackEvent } from "../utils/tracking.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";
|
import type { LibrarySearchResult, ContextResponse } from "../types.js";
|
||||||
|
|
||||||
const isTTY = process.stdout.isTTY;
|
const isTTY = process.stdout.isTTY;
|
||||||
@@ -18,12 +18,6 @@ function getReputationLabel(score: number | undefined): "High" | "Medium" | "Low
|
|||||||
return "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 {
|
function formatLibraryResult(lib: LibrarySearchResult, index: number): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`${pc.dim(`${index + 1}.`)} ${pc.bold(`Title: ${lib.title}`)}`);
|
lines.push(`${pc.dim(`${index + 1}.`)} ${pc.bold(`Title: ${lib.title}`)}`);
|
||||||
@@ -57,7 +51,7 @@ async function resolveCommand(
|
|||||||
trackEvent("command", { name: "library" });
|
trackEvent("command", { name: "library" });
|
||||||
|
|
||||||
const spinner = isTTY ? ora(`Searching for "${library}"...`).start() : null;
|
const spinner = isTTY ? ora(`Searching for "${library}"...`).start() : null;
|
||||||
const accessToken = getAccessToken();
|
const accessToken = await getValidAccessToken();
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
@@ -137,9 +131,8 @@ async function queryCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = getAccessToken();
|
|
||||||
|
|
||||||
const spinner = isTTY ? ora(`Fetching docs for "${libraryId}"...`).start() : null;
|
const spinner = isTTY ? ora(`Fetching docs for "${libraryId}"...`).start() : null;
|
||||||
|
const accessToken = await getValidAccessToken();
|
||||||
const outputType = options.json ? "json" : "txt";
|
const outputType = options.json ? "json" : "txt";
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
generateSkillStructured,
|
generateSkillStructured,
|
||||||
getSkillQuota,
|
getSkillQuota,
|
||||||
} from "../utils/api.js";
|
} from "../utils/api.js";
|
||||||
import { loadTokens, isTokenExpired } from "../utils/auth.js";
|
import { getValidAccessToken } from "../utils/auth.js";
|
||||||
import { performLogin } from "./auth.js";
|
import { performLogin } from "./auth.js";
|
||||||
import { log } from "../utils/logger.js";
|
import { log } from "../utils/logger.js";
|
||||||
import { promptForInstallTargets, getTargetDirs } from "../utils/ide.js";
|
import { promptForInstallTargets, getTargetDirs } from "../utils/ide.js";
|
||||||
@@ -60,18 +60,16 @@ async function generateCommand(options: GenerateOptions): Promise<void> {
|
|||||||
trackEvent("command", { name: "generate" });
|
trackEvent("command", { name: "generate" });
|
||||||
log.blank();
|
log.blank();
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
let accessToken = await getValidAccessToken();
|
||||||
const tokens = loadTokens();
|
if (!accessToken) {
|
||||||
if (tokens && !isTokenExpired(tokens)) {
|
|
||||||
accessToken = tokens.access_token;
|
|
||||||
} else {
|
|
||||||
log.info("Authentication required. Logging in...");
|
log.info("Authentication required. Logging in...");
|
||||||
log.blank();
|
log.blank();
|
||||||
accessToken = await performLogin();
|
const token = await performLogin();
|
||||||
if (!accessToken) {
|
if (!token) {
|
||||||
log.error("Login failed. Please try again.");
|
log.error("Login failed. Please try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
accessToken = token;
|
||||||
log.blank();
|
log.blank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { detectProjectDependencies } from "../utils/deps.js";
|
import { detectProjectDependencies } from "../utils/deps.js";
|
||||||
import { loadTokens, isTokenExpired } from "../utils/auth.js";
|
import { getValidAccessToken } from "../utils/auth.js";
|
||||||
|
|
||||||
const SKILL_HUB_DEPRECATION_WARNING =
|
const SKILL_HUB_DEPRECATION_WARNING =
|
||||||
"Warning: Skill commands are deprecated and will stop working in the next major release.";
|
"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
|
// Step 2: Single API call to backend
|
||||||
const searchSpinner = ora("Finding matching skills...").start();
|
const searchSpinner = ora("Finding matching skills...").start();
|
||||||
|
|
||||||
const tokens = loadTokens();
|
const accessToken = await getValidAccessToken();
|
||||||
const accessToken = tokens && !isTokenExpired(tokens) ? tokens.access_token : undefined;
|
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
* 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
|
* with a `refresh_token`; new installs hold long-lived API keys that never
|
||||||
* expire and skip the refresh path entirely.
|
* expire and skip the refresh path entirely.
|
||||||
*/
|
*/
|
||||||
export async function getValidAccessToken(): Promise<string | null> {
|
export async function getValidAccessToken(): Promise<string | undefined> {
|
||||||
const tokens = loadTokens();
|
const tokens = loadTokens();
|
||||||
if (!tokens) return null;
|
if (!tokens) return undefined;
|
||||||
|
|
||||||
if (!isTokenExpired(tokens)) {
|
if (!isTokenExpired(tokens)) {
|
||||||
return tokens.access_token;
|
return tokens.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokens.refresh_token) {
|
if (!tokens.refresh_token) {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newTokens = await refreshAccessToken(tokens.refresh_token);
|
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;
|
return newTokens.access_token;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user