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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 09:00:59 -07:00
parent 92ba45cc01
commit ce9df297eb
11 changed files with 488 additions and 3 deletions

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — the status route and the engine's OAuthExpiryMonitor previously diverged: a subscription OAuth credential with a past or missing/non-numeric `expires` could still read authenticated:true from /api/auth/status even though the monitor had already fired an oauth-token-expired notification for it. `/api/auth/status` now treats any OAuth credential lacking a usable numeric `expires` — and any credential whose numeric `expires` is in the past and cannot be refreshed — as expired:true/authenticated:false, for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. Settings → Authentication and the global re-login banner both read this corrected status, so an expired-and-unrefreshable subscription now consistently shows as not connected everywhere. -->
<!-- FNXC:ClaudeOAuth 2026-07-05-00:00: FN-7574 — beyond the reactive best-effort refresh on the /auth/status poll, the engine now runs an independent background OAuthRefreshScheduler (packages/engine/src/notification/oauth-refresh-scheduler.ts) on a 5-minute interval, guarded by the same `skipNotifier` option as OAuthExpiryMonitor. It proactively calls the existing refresh-if-due logic in auth-storage.ts for every known OAuth provider (plus the anthropic-subscription alias) so a healthy subscription's access token is renewed well ahead of expiry via the stored refresh token, instead of only refreshing reactively when something happens to request a runtime API key. Only providerId/providerName/expiresAt are ever logged — never token material. -->
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

View File

@@ -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.
<!-- FNXC:ClaudeOAuth 2026-07-05-00:00: FN-7574 — the proactive-refresh buffer was widened from 60s to 5 minutes (`OAUTH_REFRESH_BUFFER_MS` in packages/engine/src/auth-storage.ts) so a credential nearing expiry is treated as due-for-refresh earlier, without needlessly refreshing well-valid tokens. An engine-side background `OAuthRefreshScheduler` also polls every 5 minutes and reuses this same refresh-if-due logic against every known OAuth provider (plus the anthropic-subscription alias), so healthy subscriptions renew ahead of expiry even if nothing else happens to request a runtime API key in that window. In-flight refresh dedupe and the 30s post-failure cooldown (`OAUTH_REFRESH_FAILURE_COOLDOWN_MS`) still apply. -->
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.
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — `/api/auth/status` and the engine's OAuthExpiryMonitor previously diverged on what counted as "expired": an oauth-typed credential with a past or missing/non-numeric `expires` could still read authenticated:true from the status route, even after the monitor's oauth-token-expired notification fired. The status route now fails safe: any OAuth credential lacking a usable numeric `expires`, or whose numeric `expires` is in the past and cannot be refreshed, reports expired:true/authenticated:false for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. -->
### Anthropic API-key authentication
Anthropic has three independent authentication/routing paths:

View File

@@ -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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic" ? ({
type: "oauth",
access: "expired-legacy-token",
refresh: "revoked-refresh-token",
expires: now - 60_000,
}) : undefined);
(authStorage.getApiKey as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockImplementation(

View File

@@ -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;
}

View File

@@ -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": {

View File

@@ -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";

View File

@@ -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<string, { type: string; access: string; refresh: string; expires: number }> = {
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<string, { type: string; access: string; refresh: string; expires: number }> = {
"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<string, { type: string; access: string; refresh: string; expires: number }> = {
"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);
});
});

View File

@@ -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";

View File

@@ -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> | 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<void> {
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<string>();
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<void> {
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}`);
}
}
}
}

View File

@@ -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();