Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1179,8 +1179,16 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
...(a.needsReauth ? { needsReauth: true } : {}),
expiresAt: a.credential.expires,
}));

// A stored credential counts as "logged in" when it exists and is not marked for
// re-authentication. An expired access token with a valid refresh token is still
// logged in: request resolution refreshes expired/near-expiry credentials lazily.
// Invalid/unknown local-import expiries are handled at parse/adoption time
// (local-token-detect.ts), never by over-reporting login state here.
const activeNeedsReauth = set?.accounts
.find(a => a.id === set.activeAccountId)?.needsReauth === true;
return {
loggedIn: !!cred,
loggedIn: !!cred && !activeNeedsReauth,
email: maskEmail(cred?.email) ?? undefined,
source: cred?.source,
error: st?.error,
Expand Down
13 changes: 11 additions & 2 deletions src/oauth/local-token-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export function detectGrokCliToken(): OAuthCredentials | null {

const accessToken = entry.key as string;
const refreshToken = entry.refresh_token as string;
const expiresAt = entry.expires_at ? new Date(entry.expires_at as string).getTime() : 0;
const parsedExpiresAt = entry.expires_at ? new Date(entry.expires_at as string).getTime() : 0;
// Guard against unparseable/NaN expiries: a non-finite value must never be treated as
// "valid forever". Unknown → 0, which forces the refresh-validation path downstream.
const expiresAt = Number.isFinite(parsedExpiresAt) ? parsedExpiresAt : 0;

return {
refresh: refreshToken,
Expand Down Expand Up @@ -55,6 +58,9 @@ export function shouldAdoptGrokGeneration(
now = Date.now(),
refreshSkewMs = 60_000,
): boolean {
// A non-finite disk expiry means we cannot reason about the generation: the credential is
// either garbage or unknown. Treat it as requiring refresh validation, never as an upgrade.
if (!Number.isFinite(disk.expires)) return false;
if (disk.expires <= now + refreshSkewMs) return false;
const bothExpiriesExist = stored.expires > 0 && disk.expires > 0;
if (bothExpiriesExist) return disk.expires >= stored.expires;
Expand Down Expand Up @@ -108,7 +114,10 @@ export function parseClaudeOauthPayload(raw: string): OAuthCredentials | null {
const data = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string; expiresAt?: number } };
const o = data.claudeAiOauth;
if (!o?.accessToken || !o?.refreshToken) return null;
return { access: o.accessToken, refresh: o.refreshToken, expires: o.expiresAt ?? 0, source: "local-cli" };
// Number.isFinite guard: a string/NaN expiresAt must not flow into downstream time
// comparisons as a "valid forever" value. Unknown → 0 (refresh-validation path).
const expires = typeof o.expiresAt === "number" && Number.isFinite(o.expiresAt) ? o.expiresAt : 0;
return { access: o.accessToken, refresh: o.refreshToken, expires, source: "local-cli" };
} catch {
return null;
}
Expand Down
90 changes: 89 additions & 1 deletion tests/local-token-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ import { afterEach, beforeAll, afterAll, describe, expect, test } from "bun:test
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseClaudeOauthPayload, readClaudeCredentialsFile } from "../src/oauth/local-token-detect";
import {
detectGrokCliToken,
parseClaudeOauthPayload,
readClaudeCredentialsFile,
shouldAdoptGrokGeneration,
} from "../src/oauth/local-token-detect";

let tmp: string;
let prevConfigDir: string | undefined;
let prevHome: string | undefined;

beforeAll(() => {
tmp = mkdtempSync(join(tmpdir(), "ocx-claude-detect-"));
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
prevHome = process.env.HOME;
});

afterAll(() => {
Expand All @@ -19,6 +26,8 @@ afterAll(() => {
afterEach(() => {
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
});

describe("Claude Code credentials file fallback (Linux/Windows)", () => {
Expand All @@ -45,3 +54,82 @@ describe("Claude Code credentials file fallback (Linux/Windows)", () => {
expect(parseClaudeOauthPayload("not json")).toBeNull();
});
});

describe("invalid expiry handling (NaN guard)", () => {
test("parseClaudeOauthPayload coerces a string expiresAt to unknown (0) instead of NaN", () => {
const raw = JSON.stringify({ claudeAiOauth: { accessToken: "at-1", refreshToken: "rt-1", expiresAt: "garbage" } });
const creds = parseClaudeOauthPayload(raw);
expect(creds).not.toBeNull();
expect(creds!.expires).toBe(0);
expect(Number.isFinite(creds!.expires)).toBe(true);
});

test("parseClaudeOauthPayload coerces a non-finite expiresAt to unknown (0)", () => {
const raw = JSON.stringify({ claudeAiOauth: { accessToken: "at-1", refreshToken: "rt-1", expiresAt: NaN } });
const creds = parseClaudeOauthPayload(raw);
expect(creds).not.toBeNull();
expect(creds!.expires).toBe(0);
});

test("detectGrokCliToken coerces an unparseable expires_at to unknown (0) instead of NaN", () => {
const grokHome = join(tmp, "grok-home");
mkdirSync(join(grokHome, ".grok"), { recursive: true });
writeFileSync(join(grokHome, ".grok", "auth.json"), JSON.stringify({
"https://auth.x.ai::1": {
key: "xai-stub-access",
refresh_token: "xai-stub-refresh",
expires_at: "not-a-date",
},
}));
process.env.HOME = grokHome;

const creds = detectGrokCliToken();
expect(creds).not.toBeNull();
expect(creds!.expires).toBe(0);
expect(Number.isFinite(creds!.expires)).toBe(true);
});

test("detectGrokCliToken passes through a parseable future expires_at", () => {
const grokHome = join(tmp, "grok-home-future");
mkdirSync(join(grokHome, ".grok"), { recursive: true });
const future = new Date(Date.now() + 3600_000).toISOString();
writeFileSync(join(grokHome, ".grok", "auth.json"), JSON.stringify({
"https://auth.x.ai::1": {
key: "xai-stub-access",
refresh_token: "xai-stub-refresh",
expires_at: future,
},
}));
process.env.HOME = grokHome;

const creds = detectGrokCliToken();
expect(creds).not.toBeNull();
expect(creds!.expires).toBeGreaterThan(Date.now());
});

test("detectGrokCliToken returns null when auth.json is absent", () => {
process.env.HOME = join(tmp, "grok-home-missing");
expect(detectGrokCliToken()).toBeNull();
});
});

describe("shouldAdoptGrokGeneration with NaN/unknown expiries", () => {
const stored = { refresh: "stored-refresh", access: "stored-access", expires: Date.now() + 3600_000 };

test("never adopts a disk credential with a non-finite expiry", () => {
expect(shouldAdoptGrokGeneration(stored, { ...stored, expires: Number.NaN }, Date.now(), 60_000)).toBe(false);
});

test("rejects an unknown (0) disk expiry as requiring refresh", () => {
expect(shouldAdoptGrokGeneration(stored, { ...stored, expires: 0 }, Date.now(), 60_000)).toBe(false);
});

test("rejects an already-expired disk credential", () => {
expect(shouldAdoptGrokGeneration(stored, { ...stored, expires: Date.now() - 60_000 }, Date.now(), 60_000)).toBe(false);
});

test("adopts a newer valid disk credential", () => {
const disk = { ...stored, expires: Date.now() + 7200_000 };
expect(shouldAdoptGrokGeneration(stored, disk, Date.now(), 60_000)).toBe(true);
});
});
18 changes: 18 additions & 0 deletions tests/oauth-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,24 @@ describe("oauth refresh hardening", () => {
expect(getCredential("xai")?.source).toBe("local-cli");
});

test("malformed Grok generation is not adopted and falls through to refresh-resolution", async () => {
await saveCredential("xai", {
access: "xai-old", refresh: "rt-old", expires: Date.now() - 1, accountId: "user-1", source: "local-cli",
});
// Disk auth.json carries an unparseable expires_at; before the NaN guard this
// generation was adopted as authoritative and never refreshed.
seedGrokAuth({
key: "xai-disk", refresh_token: "rt-disk", expires_at: "not-a-date", user_id: "user-1",
});
const mock = mockXaiRefreshFetch();

await expect(getValidAccessToken("xai")).resolves.toBe("xai-fresh");
expect(mock.discoveryCount()).toBe(1);
expect(mock.tokenCount()).toBe(1);
expect(new URLSearchParams(mock.tokenBodies[0]).get("refresh_token")).toBe("rt-old");
expect(getCredential("xai")?.source).toBe("oauth");
});

test("newer-expiry Grok access token is adopted when refresh generation is unchanged", async () => {
const mock = mockRefreshFetch([new Response("unexpected", { status: 500 })]);
await saveCredential("xai", {
Expand Down
52 changes: 52 additions & 0 deletions tests/oauth-status-privacy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,58 @@ describe("OAuth status privacy", () => {
expect(JSON.stringify(status)).not.toContain("oauth<script>");
});

test("getLoginStatus stays logged in for an expired-but-refreshable credential", async () => {
await saveCredential("xai", {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() - 60_000,
email: "person@example.test",
accountId: "acct-xai",
source: "local-cli",
});

// Expired access token with a valid refresh token is still a logged-in account:
// request resolution refreshes it lazily. Only needsReauth is authoritative.
const status = getLoginStatus("xai");
expect(status.loggedIn).toBe(true);
expect(status.accounts?.[0]?.needsReauth).toBeUndefined();
});

test("getLoginStatus stays logged in for an unknown (0) credential expiry", async () => {
writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({
xai: {
access: "access-token",
refresh: "refresh-token",
expires: 0,
},
}), "utf8");

expect(getLoginStatus("xai").loggedIn).toBe(true);
});

test("getLoginStatus stays logged in for a non-finite credential expiry", async () => {
// JSON.stringify cannot carry NaN/Infinity, but a hand-written auth.json with an
// out-of-range numeric expiry parses to Infinity — the realistic corrupt shape.
writeFileSync(join(TEST_DIR, "auth.json"), '{"xai":{"access":"access-token","refresh":"refresh-token","expires":1e999}}', "utf8");

expect(getLoginStatus("xai").loggedIn).toBe(true);
});

test("getLoginStatus reports not logged in for a needsReauth account", async () => {
await saveCredential("xai", {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 3600_000,
accountId: "acct-xai",
source: "local-cli",
});
const { markAccountNeedsReauth, getAccountSet } = await import("../src/oauth/store");
await markAccountNeedsReauth("xai", getAccountSet("xai")!.activeAccountId, true);

expect(getLoginStatus("xai").loggedIn).toBe(false);
expect(getLoginStatus("xai").accounts?.[0]?.needsReauth).toBe(true);
});

test("stale credentials for removed OAuth providers fail as unsupported provider config", async () => {
await saveCredential("removed-provider", {
access: "access-token",
Expand Down
Loading