From ce9df297eb00c602293025280d847db24c17438f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 09:00:59 -0700 Subject: [PATCH] FN-7574: fix OAuth token expiry detection and add proactive auto-refresh Unifies OAuth expiry detection so expired Claude subscription logins correctly show as disconnected with a re-login prompt, and adds a proactive engine-side scheduler that refreshes tokens before they expire. - Share expiry-detection logic between OAuthExpiryMonitor and the /api/auth/status route so both agree on when a token is expired. - Add engine-side oauth-refresh-scheduler that proactively refreshes OAuth tokens ahead of expiry, wired into project-engine (guarded by skipNotifier). - Extend auth-storage with the helpers needed for expiry checks/refresh. - Add tests covering routes-auth status detection, auth-storage expiry helpers, and the new refresh scheduler. - Document the new behavior in dashboard-guide.md and settings-reference.md. - Add changeset for the user-facing fix. Files changed: .../fn-7574-oauth-expiry-detection-refresh.md | 7 + docs/dashboard-guide.md | 4 + docs/settings-reference.md | 4 + .../dashboard/src/__tests__/routes-auth.test.ts | 76 +++++++++++ .../dashboard/src/routes/register-auth-routes.ts | 25 +++- packages/engine/src/__tests__/auth-storage.test.ts | 60 +++++++++ packages/engine/src/auth-storage.ts | 14 +- .../__tests__/oauth-refresh-scheduler.test.ts | 141 ++++++++++++++++++++ packages/engine/src/notification/index.ts | 3 + .../src/notification/oauth-refresh-scheduler.ts | 143 +++++++++++++++++++++ packages/engine/src/project-engine.ts | 14 +- 11 files changed, 488 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7574 Fusion-Task-Lineage: 59996eac-c070-4992-9727-d066c6934b69 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7574-oauth-expiry-detection-refresh.md | 7 + docs/dashboard-guide.md | 4 + docs/settings-reference.md | 4 + .../src/__tests__/routes-auth.test.ts | 76 ++++++++++ .../src/routes/register-auth-routes.ts | 25 ++- .../engine/src/__tests__/auth-storage.test.ts | 60 ++++++++ packages/engine/src/auth-storage.ts | 14 +- .../__tests__/oauth-refresh-scheduler.test.ts | 141 +++++++++++++++++ packages/engine/src/notification/index.ts | 3 + .../notification/oauth-refresh-scheduler.ts | 143 ++++++++++++++++++ packages/engine/src/project-engine.ts | 14 +- 11 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7574-oauth-expiry-detection-refresh.md create mode 100644 packages/engine/src/notification/__tests__/oauth-refresh-scheduler.test.ts create mode 100644 packages/engine/src/notification/oauth-refresh-scheduler.ts diff --git a/.changeset/fn-7574-oauth-expiry-detection-refresh.md b/.changeset/fn-7574-oauth-expiry-detection-refresh.md new file mode 100644 index 0000000000..1624b75079 --- /dev/null +++ b/.changeset/fn-7574-oauth-expiry-detection-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Expired Claude subscription logins now show disconnected with a re-login prompt; tokens auto-refresh before expiry. +category: fix +dev: Unifies OAuth expiry detection between OAuthExpiryMonitor and /api/auth/status, and adds an engine-side proactive OAuth refresh scheduler wired in project-engine (guarded by skipNotifier). No token material logged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 07e9d30bdb..37a2e8cfff 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -705,6 +705,10 @@ For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attemp If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops. + + + + Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. When Anthropic Subscription is expired but Anthropic API Key or Anthropic — via Claude CLI is already authenticated, the global banner suppresses only the urgent subscription re-login entry so it does not imply agents are blocked; Settings still shows the subscription OAuth card as expired/not connected and re-login remains available. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved. ## Setup Warning Banner diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 3443cc6f53..a6054519a3 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -761,8 +761,12 @@ Recovery entrypoints in the dashboard: Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login. + + Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. The top-level dashboard re-login banner suppresses only the urgent **Anthropic Subscription** entry when **Anthropic API Key** or **Anthropic — via Claude CLI** is already authenticated, so the banner does not imply all Anthropic agent execution is blocked; Settings still shows the subscription OAuth state as expired/not connected until it is refreshed or re-logged-in. + + ### Anthropic API-key authentication Anthropic has three independent authentication/routing paths: diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 9283925c0b..a0de003484 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -1184,6 +1184,82 @@ describe("GET /auth/status", () => { expect(anthropic).toMatchObject({ authenticated: false, expired: true }); }); + /* + FNXC:ProviderAuth 2026-07-05-00:00: + FN-7574 symptom verification: an expired, unrefreshable Anthropic Subscription OAuth + credential must report authenticated:false/expired:true across BOTH the legacy + pre-split `anthropic`-row storage permutation and the separated `anthropic-subscription`- + row permutation, so the settings card and OAuthReloniBanner never show a lapsed + subscription as connected. Covers the exact reproduction from the task's Symptom + Verification section. + */ + it("FN-7574: reports expired-and-unrefreshable subscription OAuth as not-connected (separated anthropic-subscription row)", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ + type: "oauth", + access: "expired-token", + refresh: "revoked-refresh-token", + expires: now - 60_000, + }) : undefined); + // Simulate a revoked refresh token: the best-effort refresh attempt inside the + // status route fails (non-200 from the OAuth token endpoint), so getApiKey resolves + // to undefined without throwing. + (authStorage.getApiKey as ReturnType).mockResolvedValue(undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + }); + + it("FN-7574: reports expired-and-unrefreshable subscription OAuth as not-connected (legacy anthropic row)", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic" ? ({ + type: "oauth", + access: "expired-legacy-token", + refresh: "revoked-refresh-token", + expires: now - 60_000, + }) : undefined); + (authStorage.getApiKey as ReturnType).mockResolvedValue(undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + }); + + it("FN-7574: treats an oauth credential missing a numeric expires as expired, not authenticated", async () => { + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ + type: "oauth", + access: "token-without-expiry", + refresh: "refresh", + // expires intentionally omitted — a partially-hydrated/corrupted credential. + }) : undefined); + (authStorage.getApiKey as ReturnType).mockResolvedValue(undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + }); + it("reports loginInProgress for oauth providers with active logins", async () => { let releaseLogin: (() => void) | undefined; (authStorage.login as ReturnType).mockImplementation( diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 503b3c30fc..2e04baf7ff 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -82,11 +82,34 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return undefined; } + /* + FNXC:ProviderAuth 2026-07-05-00:00: + FN-7574: an expired-and-unrefreshable subscription OAuth credential was reported as + `authenticated:true` on the settings card and never surfaced the re-login banner, + even though OAuthExpiryMonitor (engine side) had already fired the oauth-token-expired + notification for the same credential. Root cause enumerated in task notes: a stored + OAuth-typed credential with a missing/non-numeric `expires` field was previously + treated as "not expired" here, while `resolveOAuthApiKey`/`getApiKey` in + packages/engine/src/auth-storage.ts already treat a missing numeric `expires` as + unusable (never yields a runtime key). Make the status predicate fail-safe: a stored + OAuth credential without a usable numeric expiry now reports `expired:true` (shows as + not-connected) rather than silently claiming a live session it cannot actually use. + OAuthExpiryMonitor intentionally keeps its separate skip-and-don't-notify behavior for + unknown-expiry credentials (see oauth-expiry-monitor.ts) — the notification channel + should stay conservative about spamming, while this "are you logged in" status surface + should stay conservative about claiming a live session. + */ function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean { const credential = getOauthStatusCredential(providerId, storage); - if (!credential || typeof credential.expires !== "number") { + if (!credential) { return false; } + if (typeof credential.expires !== "number" || !Number.isFinite(credential.expires)) { + // A stored OAuth credential with no usable expiry can never mint a runtime API + // key (see resolveOAuthApiKey in auth-storage.ts), so treat it as expired rather + // than authenticated. + return true; + } return Date.now() >= credential.expires; } diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 01875d737a..168254e7d4 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -392,6 +392,66 @@ describe("createFusionAuthStorage", () => { expect(persisted.anthropic).toBeUndefined(); }); + /* + FNXC:ClaudeOAuth 2026-07-05-00:00: + FN-7574 symptom verification: a healthy subscription OAuth credential that is still + within its validity window but nearing expiry must be refreshed proactively — + BEFORE it actually expires — the first time something reads it (e.g. the periodic + OAuthRefreshScheduler tick), not only reactively once it has already lapsed. + */ + it("proactively refreshes subscription OAuth nearing expiry, ahead of actual expiration", async () => { + const now = Date.now(); + writeFusionAuth(homeDir, { + "anthropic-subscription": { + type: "oauth", + access: "soon-to-expire-access-token", + refresh: "subscription-refresh-token", + // Still valid for another 2 minutes — inside the widened proactive-refresh + // window, but not yet actually expired. + expires: now + 120_000, + }, + }); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "proactively-refreshed-access-token", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }), + } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("proactively-refreshed-access-token"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const refreshed = authStorage.get("anthropic-subscription"); + expect(refreshed).toMatchObject({ access: "proactively-refreshed-access-token" }); + expect((refreshed as { expires: number }).expires).toBeGreaterThan(now + 120_000); + }); + + it("does not proactively refresh subscription OAuth that is not yet within the refresh window", async () => { + const now = Date.now(); + writeFusionAuth(homeDir, { + "anthropic-subscription": { + type: "oauth", + access: "still-fresh-access-token", + refresh: "subscription-refresh-token", + // Comfortably outside the proactive-refresh buffer. + expires: now + 3_600_000, + }, + }); + + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("still-fresh-access-token"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("does not resurrect stale Anthropic subscription OAuth after failed refresh", async () => { writeFusionAuth(homeDir, { "anthropic-subscription": { diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 7a185e1ba3..d8d1486c79 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -16,7 +16,19 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth"; type StoredCredential = StoredAuthCredential; -const OAUTH_REFRESH_BUFFER_MS = 60_000; +/* +FNXC:ClaudeOAuth 2026-07-05-00:00: +FN-7574: a 60s reactive refresh buffer meant a healthy Anthropic subscription token was +only ever refreshed a few seconds before (or after) it actually expired — too late to +reliably beat a slow/failed network round trip, so subscriptions routinely lapsed and +forced a manual re-login even though the refresh token was still valid. Widen the +proactive-refresh window to 5 minutes so both the reactive getApiKey() path AND the new +background OAuthRefreshScheduler (see notification/oauth-refresh-scheduler.ts) renew the +access token well ahead of expiry, without refreshing needlessly often (the scheduler +runs on a multi-minute interval, and the in-flight dedupe + failure cooldown below still +apply so a single stuck token doesn't get hammered). +*/ +const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000; const ANTHROPIC_PROVIDER_ID = "anthropic"; const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; diff --git a/packages/engine/src/notification/__tests__/oauth-refresh-scheduler.test.ts b/packages/engine/src/notification/__tests__/oauth-refresh-scheduler.test.ts new file mode 100644 index 0000000000..fcabc23dd9 --- /dev/null +++ b/packages/engine/src/notification/__tests__/oauth-refresh-scheduler.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OAuthRefreshScheduler } from "../oauth-refresh-scheduler.js"; + +describe("OAuthRefreshScheduler", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("proactively refreshes an OAuth credential nearing expiry via getApiKey", async () => { + const now = Date.now(); + const credentials: Record = { + anthropic: { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 60_000 }, + }; + + const getApiKey = vi.fn(async (providerId: string) => { + const cred = credentials[providerId]; + if (!cred) return undefined; + // Simulate the real auth-storage.ts refresh-if-due behavior: rotates the token + // and pushes expiry forward when getApiKey is called while near expiry. + credentials[providerId] = { ...cred, access: "rotated-token", expires: now + 3_600_000 }; + return "rotated-token"; + }); + + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => [{ id: "anthropic", name: "Anthropic" }]), + get: vi.fn((providerId: string) => credentials[providerId]), + getApiKey, + }; + + const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now }); + await scheduler.start(); + scheduler.stop(); + + expect(getApiKey).toHaveBeenCalledWith("anthropic"); + expect(getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(credentials.anthropic.expires).toBe(now + 3_600_000); + }); + + it("also attempts refresh for the anthropic-subscription alias even though it is never returned by getOAuthProviders", async () => { + const now = Date.now(); + const credentials: Record = { + "anthropic-subscription": { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 30_000 }, + }; + + const getApiKey = vi.fn(async (providerId: string) => { + const cred = credentials[providerId]; + if (!cred) return undefined; + credentials[providerId] = { ...cred, access: "rotated", expires: now + 3_600_000 }; + return "rotated"; + }); + + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => [{ id: "anthropic", name: "Anthropic" }]), + get: vi.fn((providerId: string) => credentials[providerId]), + getApiKey, + }; + + const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now }); + await scheduler.start(); + scheduler.stop(); + + expect(getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(credentials["anthropic-subscription"].expires).toBe(now + 3_600_000); + }); + + it("attempts a cheap no-op refresh for a provider with no stored oauth credential", async () => { + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => [{ id: "github-copilot", name: "GitHub Copilot" }]), + get: vi.fn(() => undefined), + getApiKey: vi.fn(async () => undefined), + }; + + const scheduler = new OAuthRefreshScheduler({ authStorage }); + await scheduler.start(); + scheduler.stop(); + + // getApiKey() is a cheap no-op for a provider with no stored credential, so the + // scheduler still calls it (rather than special-casing "no credential yet") but + // there's nothing to refresh. + expect(authStorage.getApiKey).toHaveBeenCalledWith("github-copilot"); + }); + + it("swallows per-provider refresh failures and continues with other providers", async () => { + const now = Date.now(); + const credentials: Record = { + "anthropic-subscription": { type: "oauth", access: "old-token", refresh: "refresh", expires: now + 30_000 }, + github: { type: "oauth", access: "gh-token", refresh: "gh-refresh", expires: now + 30_000 }, + }; + + const getApiKey = vi.fn(async (providerId: string) => { + if (providerId === "anthropic" || providerId === "anthropic-subscription") { + throw new Error("revoked refresh token"); + } + const cred = credentials[providerId]; + if (!cred) return undefined; + credentials[providerId] = { ...cred, expires: now + 3_600_000 }; + return "rotated"; + }); + + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => [ + { id: "anthropic", name: "Anthropic" }, + { id: "github", name: "GitHub" }, + ]), + get: vi.fn((providerId: string) => credentials[providerId]), + getApiKey, + }; + + const scheduler = new OAuthRefreshScheduler({ authStorage, clock: () => now }); + await expect(scheduler.start()).resolves.toBeUndefined(); + scheduler.stop(); + + expect(credentials.github.expires).toBe(now + 3_600_000); + }); + + it("reloads auth storage and repeats on its interval", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => [{ id: "github-copilot", name: "GitHub Copilot" }]), + get: vi.fn(() => undefined), + getApiKey: vi.fn(async () => undefined), + }; + + const scheduler = new OAuthRefreshScheduler({ authStorage, intervalMs: 1_000, clock: () => now }); + await scheduler.start(); + expect(authStorage.reload).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1_000); + expect(authStorage.reload).toHaveBeenCalledTimes(2); + + scheduler.stop(); + await vi.advanceTimersByTimeAsync(5_000); + expect(authStorage.reload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/engine/src/notification/index.ts b/packages/engine/src/notification/index.ts index 549020023d..91d13128f2 100644 --- a/packages/engine/src/notification/index.ts +++ b/packages/engine/src/notification/index.ts @@ -14,3 +14,6 @@ export { OAuthExpiryMonitor } from "./oauth-expiry-monitor.js"; export type { AuthStorageLike as OAuthExpiryAuthStorageLike, OAuthExpiryMonitorOptions } from "./oauth-expiry-monitor.js"; export { OAuthValidityLogger } from "./oauth-validity-logger.js"; + +export { OAuthRefreshScheduler } from "./oauth-refresh-scheduler.js"; +export type { OAuthRefreshAuthStorageLike, OAuthRefreshSchedulerOptions } from "./oauth-refresh-scheduler.js"; diff --git a/packages/engine/src/notification/oauth-refresh-scheduler.ts b/packages/engine/src/notification/oauth-refresh-scheduler.ts new file mode 100644 index 0000000000..df4529ee6c --- /dev/null +++ b/packages/engine/src/notification/oauth-refresh-scheduler.ts @@ -0,0 +1,143 @@ +import { schedulerLog } from "../logger.js"; + +/* +FNXC:ClaudeOAuth 2026-07-05-00:00: +FN-7574: healthy subscriptions must not lapse waiting for a reactive refresh. A stored +OAuth credential's access token was previously only ever refreshed when something +actively requested a runtime API key (model execution, or the dashboard's best-effort +refresh-on-expiry check) — if nothing asked for a key in the window between "about to +expire" and "expired", the token simply expired and forced a manual re-login. + +OAuthRefreshScheduler runs as an independent, engine-side background loop (separate from +OAuthExpiryMonitor's detect-and-notify concern, per the task's File Scope "pick ONE and +justify": keeping detection/notification and refresh as separate, independently +toggleable responsibilities is easier to test and reason about than folding a refresh +side effect into the monitor's `check()`). On each tick it reloads auth storage and asks +for a fresh API key for every known OAuth provider (plus the Anthropic subscription +alias); `authStorage.getApiKey(id)` already contains the refresh-if-due logic — see +`shouldRefreshOAuthCredential`/`refreshProviderOAuthCredential` in `auth-storage.ts` — so +this scheduler deliberately reuses that instead of duplicating the token HTTP call. + +Never logs or persists access/refresh token material: only `providerId`, `providerName`, +and `expiresAt` (ISO) are ever referenced for observability. +*/ + +const DEFAULT_INTERVAL_MS = 5 * 60_000; + +const ANTHROPIC_OAUTH_PROVIDER_ID = "anthropic"; +const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; + +interface OAuthProviderInfo { + id: string; + name: string; +} + +interface OAuthCredential { + type?: string; + expires?: number; +} + +export interface OAuthRefreshAuthStorageLike { + reload?(): void; + getOAuthProviders?(): OAuthProviderInfo[]; + get?(providerId: string): OAuthCredential | undefined; + getApiKey?(providerId: string): Promise | string | null | undefined; +} + +export interface OAuthRefreshSchedulerOptions { + authStorage: OAuthRefreshAuthStorageLike; + intervalMs?: number; + clock?: () => number; +} + +export class OAuthRefreshScheduler { + private readonly intervalMs: number; + private readonly clock: () => number; + private timer: NodeJS.Timeout | null = null; + + constructor(private readonly opts: OAuthRefreshSchedulerOptions) { + this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; + this.clock = opts.clock ?? Date.now; + } + + async start(): Promise { + if (this.timer) { + return; + } + + await this.tick(); + this.timer = setInterval(() => { + void this.tick(); + }, this.intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (!this.timer) { + return; + } + + clearInterval(this.timer); + this.timer = null; + } + + private getRefreshCandidateIds(providers: OAuthProviderInfo[]): string[] { + const ids = new Set(); + for (const provider of providers) { + ids.add(provider.id); + if (provider.id === ANTHROPIC_OAUTH_PROVIDER_ID) { + // The dashboard-facing subscription alias is stored/refreshed under its own id + // (see selectAnthropicSubscriptionCredential in auth-storage.ts) and is never + // returned by getOAuthProviders() itself, so it must be attempted explicitly. + ids.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + } + } + return Array.from(ids); + } + + private async tick(): Promise { + this.opts.authStorage.reload?.(); + + const providers = this.opts.authStorage.getOAuthProviders?.(); + if (!providers?.length || !this.opts.authStorage.getApiKey) { + return; + } + + for (const providerId of this.getRefreshCandidateIds(providers)) { + try { + const before = this.opts.authStorage.get?.(providerId); + const beforeExpires = before?.type === "oauth" && typeof before.expires === "number" && Number.isFinite(before.expires) + ? before.expires + : undefined; + + /* + FNXC:ClaudeOAuth 2026-07-05-00:00: + Always attempt getApiKey() for every known oauth-provider id (rather than + pre-filtering on whether this scheduler's own `get()` snapshot already shows a + credential): the Anthropic subscription alias legitimately resolves through a + legacy-row fallback inside auth-storage.ts's own selection logic, so a naive + "skip if this exact id has no direct row" check would silently skip refreshing + a legacy-row subscription credential. getApiKey() is a cheap no-op when no + credential exists for that id (see resolveStoredCredentialApiKey/getApiKey). + */ + await this.opts.authStorage.getApiKey(providerId); + + const after = this.opts.authStorage.get?.(providerId); + if ( + after?.type === "oauth" + && typeof after.expires === "number" + && Number.isFinite(after.expires) + && (beforeExpires === undefined || after.expires > beforeExpires) + ) { + const providerName = providers.find((p) => p.id === providerId)?.name ?? providerId; + schedulerLog.log( + `OAuth credential proactively refreshed provider=${providerId} name=${providerName} expiresAt=${new Date(after.expires).toISOString()}`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + schedulerLog.warn(`OAuth proactive refresh failed provider=${providerId}: ${message}`); + } + } + } +} diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 2ee050dbd5..6e87f913c9 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -48,7 +48,7 @@ import type { PrNodeGithubOps } from "./pr-nodes.js"; import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js"; import { PrCommentHandler } from "./pr-comment-handler.js"; import { NtfyNotifier } from "./notifier.js"; -import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js"; +import { NotificationService, OAuthAlertStateStore, OAuthExpiryMonitor, OAuthRefreshScheduler, OAuthValidityLogger } from "./notification/index.js"; import type { NotificationChatStore } from "./notification/notification-service.js"; import { GridlockDetector } from "./gridlock-detector.js"; import { createFusionAuthStorage, getFusionOAuthAlertStatePath } from "./auth-storage.js"; @@ -396,6 +396,7 @@ export class ProjectEngine { private notifier?: NtfyNotifier; private notificationService?: NotificationService; private oauthExpiryMonitor?: OAuthExpiryMonitor; + private oauthRefreshScheduler?: OAuthRefreshScheduler; private oauthValidityLogger?: OAuthValidityLogger; private gridlockDetector?: GridlockDetector; private cronRunner?: CronRunner; @@ -721,6 +722,16 @@ export class ProjectEngine { alertState: oauthAlertState, }); await this.oauthExpiryMonitor.start(); + /* + FNXC:ClaudeOAuth 2026-07-05-00:00: + FN-7574: proactively refresh OAuth access tokens ahead of expiry (widened window, + see OAUTH_REFRESH_BUFFER_MS in auth-storage.ts) so a healthy subscription session + never lapses waiting for something else to request a runtime API key. Reuses the + same authStorage instance as OAuthExpiryMonitor above so detection/notification and + proactive refresh observe a consistent, single credential source. + */ + this.oauthRefreshScheduler = new OAuthRefreshScheduler({ authStorage }); + await this.oauthRefreshScheduler.start(); this.oauthValidityLogger = new OAuthValidityLogger({ authStorage, alertState: oauthAlertState, @@ -954,6 +965,7 @@ export class ProjectEngine { this.prReconciler?.stopAll(); this.prReconciler = undefined; this.oauthExpiryMonitor?.stop(); + this.oauthRefreshScheduler?.stop(); this.oauthValidityLogger?.stop(); this.notificationService?.stop(); this.notifier?.stop();