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:
7
.changeset/fn-7574-oauth-expiry-detection-refresh.md
Normal file
7
.changeset/fn-7574-oauth-expiry-detection-refresh.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
143
packages/engine/src/notification/oauth-refresh-scheduler.ts
Normal file
143
packages/engine/src/notification/oauth-refresh-scheduler.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user