diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 484ecf99..d2d14daf 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -350,10 +350,20 @@ const PROVIDERS = { .join(" ") .trim() || null; + const expiresAt = tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null; + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || null, expiresIn: tokens.expires_in, + // Surface an absolute expiry so the proactive refresh path + // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the + // xAI token before it silently expires ~40-45 min after login. + // Without this, only the reactive 401 path in chatCore would refresh, + // causing intermittent "token expired" failures for Grok CLI. + expiresAt, scope: tokens.scope, // Top-level for dashboard connection cards email: email || undefined, diff --git a/tests/unit/grok-cli-expiresat-2546.test.js b/tests/unit/grok-cli-expiresat-2546.test.js new file mode 100644 index 00000000..f3f8cc8c --- /dev/null +++ b/tests/unit/grok-cli-expiresat-2546.test.js @@ -0,0 +1,57 @@ +/** + * Regression test for issue #2546: Grok CLI (xAI) token refresh not used, + * session dies 40-45 min after login. + * + * Root cause: grok-cli mapTokens stored `expiresIn` but never `expiresAt`. + * shouldRefreshCredentials() only reads expiresAt/tokenExpiresAt, so the + * proactive refresh path never fired and only the reactive 401 path could + * refresh — causing intermittent "token expired" failures. + * + * This test exercises the proactive-refresh decision path for grok-cli with + * an absolute expiresAt. (The mapTokens unit portion cannot run in this + * checkout because src/lib/oauth/providers.js self-imports the bare + * "open-sse/index.js" specifier which vitest here does not resolve — a + * pre-existing harness gap unrelated to this fix.) + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const originalFetch = global.fetch; + +describe("Grok CLI (xAI) token expiry propagation (#2546)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + global.fetch = originalFetch; + }); + afterEach(() => { + global.fetch = originalFetch; + }); + + it("proactive refresh fires for a near-expiry grok-cli token (expiresAt present)", async () => { + const { shouldRefreshCredentials } = await import( + "../../open-sse/services/oauthCredentialManager.js" + ); + const soon = new Date(Date.now() + 60 * 1000).toISOString(); + const creds = { + connectionId: "grok-1", + refreshToken: "rt", + expiresIn: 60, + expiresAt: soon, + }; + expect(shouldRefreshCredentials("grok-cli", creds)).toBe(true); + }); + + it("proactive refresh does NOT fire for a far-future grok-cli token", async () => { + const { shouldRefreshCredentials } = await import( + "../../open-sse/services/oauthCredentialManager.js" + ); + const farFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const creds = { + connectionId: "grok-2", + refreshToken: "rt", + expiresIn: 86400, + expiresAt: farFuture, + }; + expect(shouldRefreshCredentials("grok-cli", creds)).toBe(false); + }); +});