diff --git a/.changeset/fn-7821-oauth-expiry-notification-banner-consistency.md b/.changeset/fn-7821-oauth-expiry-notification-banner-consistency.md new file mode 100644 index 0000000000..3b5baabbdf --- /dev/null +++ b/.changeset/fn-7821-oauth-expiry-notification-banner-consistency.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop false "OAuth token expired" push notifications for providers that silently refresh (e.g. GitHub Copilot). +category: fix +dev: OAuthExpiryMonitor.check() now attempts a best-effort getApiKey refresh and re-checks the credential before dispatching oauth-token-expired, mirroring /api/auth/status's refresh-then-recheck that drives OAuthReloginBanner. The FN-7574 start-refresher-first ordering only covered the startup check; short-lived auto-refreshing tokens still fired on interval ticks with no matching banner. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a4b941b46b..8946bf9c5d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -766,11 +766,11 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval. -For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request. +For OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the provider without manual re-login and without waiting for a separate model request. -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. +If the OAuth credential has no refresh token or the refresh request fails/leaves the credential expired, 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. - + diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 26cbe5930c..57da19483b 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -777,13 +777,13 @@ Recovery entrypoints in the dashboard: ### OAuth credential refresh -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. +Fusion automatically refreshes OAuth credentials before reporting auth status when the stored 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 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. +Manual re-login is still required when no refresh token is stored or the refresh request fails/leaves the credential expired. 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 diff --git a/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts b/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts index 45c370a932..d5952e9624 100644 --- a/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts +++ b/packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts @@ -13,8 +13,10 @@ function createStatePath(): string { return join(dir, "oauth-alert-state.json"); } -function createAuthStorage(initialCredential?: { type?: string; expires?: number }): AuthStorageLike & { - credential: { type?: string; expires?: number } | undefined; +type TestCredential = { type?: string; expires?: number }; + +function createAuthStorage(initialCredential?: TestCredential): AuthStorageLike & { + credential: TestCredential | undefined; } { return { credential: initialCredential, @@ -68,6 +70,110 @@ describe("OAuthExpiryMonitor", () => { monitor.stop(); }); + /* + FNXC:ProviderAuth 2026-07-11-18:00: + Regression coverage for FN-7821: GitHub Copilot's stored OAuth access token is intentionally short-lived and can look expired on an OAuthExpiryMonitor interval tick even though getApiKey() can silently refresh it. The monitor must attempt that refresh and re-check before dispatching so ntfy and OAuthReloginBanner do not disagree. + */ + it("does not fire for github-copilot when getApiKey refreshes the expired credential", async () => { + vi.useFakeTimers(); + const now = Date.now(); + let credential: TestCredential | undefined = { type: "oauth", expires: now - 1_000 }; + const getApiKey = vi.fn(async () => { + credential = { type: "oauth", expires: now + 60_000 }; + return "opaque-github-copilot-access-token"; + }); + const dispatch = vi.fn(async () => undefined); + const authStorage: AuthStorageLike = { + reload: vi.fn(), + getOAuthProviders: () => [{ id: "github-copilot", name: "GitHub Copilot" }], + get: (providerId: string) => providerId === "github-copilot" ? credential : undefined, + getApiKey, + }; + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(getApiKey).toHaveBeenCalledWith("github-copilot"); + expect(dispatch).not.toHaveBeenCalled(); + monitor.stop(); + }); + + it("still fires exactly once for github-copilot when refresh throws and never logs token material in metadata", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const getApiKey = vi.fn(async () => { + throw new Error("refresh failed"); + }); + const dispatch = vi.fn(async () => undefined); + const authStorage: AuthStorageLike = { + reload: vi.fn(), + getOAuthProviders: () => [{ id: "github-copilot", name: "GitHub Copilot" }], + get: (providerId: string) => providerId === "github-copilot" + ? { type: "oauth", expires: now - 1_000 } + : undefined, + getApiKey, + }; + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(getApiKey).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith( + "oauth-token-expired", + expect.objectContaining({ + metadata: { + providerId: "github-copilot", + providerName: "GitHub Copilot", + expiresAt: new Date(now - 1_000).toISOString(), + }, + }), + ); + expect(JSON.stringify(dispatch.mock.calls)).not.toContain("opaque-github-copilot-access-token"); + monitor.stop(); + }); + + it("still fires for openai-codex when refresh leaves the credential expired", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const authStorage = createAuthStorage({ type: "oauth", expires: now - 1_000 }); + const getApiKey = vi.fn(async () => "opaque-openai-codex-access-token"); + authStorage.getApiKey = getApiKey; + const dispatch = vi.fn(async () => undefined); + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(getApiKey).toHaveBeenCalledWith("openai-codex"); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(JSON.stringify(dispatch.mock.calls)).not.toContain("opaque-openai-codex-access-token"); + monitor.stop(); + }); + /* FNXC:ClaudeOAuth 2026-07-08-20:55: Regression: getOAuthProviders() only yields base `anthropic`, and get("anthropic") can @@ -109,6 +215,41 @@ describe("OAuthExpiryMonitor", () => { monitor.stop(); }); + it("does not fire for anthropic when getApiKey refreshes the effective subscription alias", async () => { + vi.useFakeTimers(); + const now = Date.now(); + const dispatch = vi.fn(async () => undefined); + const credentials: Record = { + anthropic: { type: "oauth", expires: now - 60 * 24 * 60 * 60 * 1000 }, + "anthropic-subscription": { type: "oauth", expires: now - 1_000 }, + }; + const getApiKey = vi.fn(async () => { + credentials["anthropic-subscription"] = { type: "oauth", expires: now + 5 * 60 * 60 * 1000 }; + return "opaque-anthropic-subscription-token"; + }); + const authStorage: AuthStorageLike = { + reload: vi.fn(), + getOAuthProviders: () => [{ id: "anthropic", name: "Anthropic" }], + get: (providerId: string) => credentials[providerId], + getApiKey, + }; + + const monitor = new OAuthExpiryMonitor({ + authStorage, + notificationService: { dispatch } as any, + intervalMs: 100, + clock: () => now, + alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }), + }); + + await monitor.start(); + await vi.runOnlyPendingTimersAsync(); + + expect(getApiKey).toHaveBeenCalledWith("anthropic"); + expect(dispatch).not.toHaveBeenCalled(); + monitor.stop(); + }); + it("still fires for anthropic when both the legacy row and the subscription alias are expired", async () => { vi.useFakeTimers(); const now = Date.now(); @@ -154,6 +295,7 @@ describe("OAuthExpiryMonitor", () => { const cases: Array<{ type?: string; expires?: number } | undefined> = [ { type: "api_key" }, { type: "oauth" }, + { type: "oauth", expires: Number.NaN }, { type: "oauth", expires: now + 60_000 }, undefined, ]; diff --git a/packages/engine/src/notification/oauth-expiry-monitor.ts b/packages/engine/src/notification/oauth-expiry-monitor.ts index 5abfbf7edb..71c945fafc 100644 --- a/packages/engine/src/notification/oauth-expiry-monitor.ts +++ b/packages/engine/src/notification/oauth-expiry-monitor.ts @@ -23,11 +23,15 @@ export interface AuthStorageLike { reload?(): void; getOAuthProviders?(): OAuthProviderInfo[]; get?(providerId: string): OAuthCredential | undefined; + getApiKey?(providerId: string): Promise | string | null | undefined; } /* FNXC:ClaudeOAuth 2026-07-08-20:55: `getOAuthProviders()` is NOT aliased by the engine auth-storage proxy, so it only yields the base id `anthropic`. But the Anthropic subscription token the runtime actually uses/refreshes lives under `anthropic-subscription`, while `get("anthropic")` can still return a STALE legacy row (e.g. a months-old credential in ~/.pi/agent/auth.json). Evaluating `get("anthropic")` alone made the expiry monitor and validity logger fire a false "Anthropic OAuth expired" alert even though the subscription token had refreshed successfully. Resolve the FRESHEST of the two aliased ids so a live subscription token suppresses the false alert — mirroring the refresh scheduler's alias handling (getRefreshCandidateIds in oauth-refresh-scheduler.ts). + +FNXC:ClaudeOAuth 2026-07-11-18:00: +OAuthExpiryMonitor must refresh-then-recheck before firing `oauth-token-expired` so ntfy observes the same manual re-login truth as `/api/auth/status`, which already calls `getApiKey()` and recomputes expiry for OAuthReloginBanner. The FN-7574 start-refresher-before-monitor ordering only protected the startup check; short-lived auto-refreshing credentials such as GitHub Copilot's ephemeral token can still expire between interval ticks and silently refresh moments later, so dispatching from the stored timestamp alone creates false pushes with no matching banner. */ export function resolveEffectiveOAuthCredential( authStorage: AuthStorageLike, @@ -39,7 +43,7 @@ export function resolveEffectiveOAuthCredential( } const subscription = authStorage.get?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); const candidates = [direct, subscription].filter( - (c): c is OAuthCredential => c?.type === "oauth" && typeof c.expires === "number", + (c): c is OAuthCredential => c?.type === "oauth" && typeof c.expires === "number" && Number.isFinite(c.expires), ); if (candidates.length === 0) { return direct; @@ -109,22 +113,54 @@ export class OAuthExpiryMonitor { const activeExpiryKeys = new Set(); for (const provider of providers) { - const credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id); - if (credential?.type !== "oauth" || typeof credential.expires !== "number") { + let credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id); + if (credential?.type !== "oauth" || typeof credential.expires !== "number" || !Number.isFinite(credential.expires)) { this.alertState.clear([provider.id]); continue; } - const expiryKey = `${provider.id}:${credential.expires}`; - activeExpiryKeys.add(expiryKey); - + let expiryKey = `${provider.id}:${credential.expires}`; if (now + this.warnBeforeMs < credential.expires) { + activeExpiryKeys.add(expiryKey); continue; } if (this.dispatchedExpiryKeys.has(expiryKey)) { + activeExpiryKeys.add(expiryKey); continue; } + if (this.opts.authStorage.getApiKey) { + try { + /* + FNXC:ClaudeOAuth 2026-07-11-18:00: + The expiry monitor must reuse authStorage.getApiKey() as the single refresh side effect and then reload/re-resolve the effective credential before notifying. This keeps `oauth-token-expired` aligned with the banner-driving `/api/auth/status` route without duplicating provider-specific token refresh code or logging token material. + */ + await this.opts.authStorage.getApiKey(provider.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + schedulerLog.warn(`OAuth expiry refresh-before-notify failed provider=${provider.id}: ${message}`); + } + + this.opts.authStorage.reload?.(); + credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id); + if (credential?.type !== "oauth" || typeof credential.expires !== "number" || !Number.isFinite(credential.expires)) { + this.alertState.clear([provider.id]); + continue; + } + + expiryKey = `${provider.id}:${credential.expires}`; + if (now + this.warnBeforeMs < credential.expires) { + activeExpiryKeys.add(expiryKey); + continue; + } + if (this.dispatchedExpiryKeys.has(expiryKey)) { + activeExpiryKeys.add(expiryKey); + continue; + } + } + + activeExpiryKeys.add(expiryKey); + const previousNotificationAt = this.alertState.getLastAlertAt(provider.id); if ( typeof previousNotificationAt === "number" &&