feat(FN-5595): add oauth relogin banner with validity logger
This merge implements an OAuth relogin banner feature (FN-5595) that displays in the dashboard when OAuth tokens expire. The feature includes a new `OAuthReloginBanner` component with styling and tests, an OAuth validity logger in the engine for tracking token state, and corresponding API route inte Fusion-Task-Id: FN-5595 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai> Fusion-Task-Id: FN-5595
This commit is contained in:
5
.changeset/FN-5595-oauth-relogin-banner.md
Normal file
5
.changeset/FN-5595-oauth-relogin-banner.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Dashboard now shows a top-level "Re-login required" banner when a stored OAuth provider credential (Codex, Claude, etc.) has expired, and the engine logs the expired set on startup and once every 24 hours.
|
||||
@@ -25,6 +25,7 @@ import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
|
||||
import { TestModeBanner } from "./components/TestModeBanner";
|
||||
import { OAuthReloginBanner } from "./components/OAuthReloginBanner";
|
||||
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
|
||||
import { DbCorruptionBanner } from "./components/DbCorruptionBanner";
|
||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||
@@ -1794,7 +1795,12 @@ function AppInner() {
|
||||
}
|
||||
/>
|
||||
{viewMode === "project" && currentProject && (
|
||||
<TestModeBanner isActive={isTestMode} />
|
||||
<>
|
||||
<TestModeBanner isActive={isTestMode} />
|
||||
<OAuthReloginBanner
|
||||
onReLogin={(_providerId) => modalManager.openSettings("authentication" as SectionId)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
|
||||
<SessionNotificationBanner
|
||||
|
||||
@@ -1441,6 +1441,8 @@ export interface AuthProvider {
|
||||
authenticated: boolean;
|
||||
/** True when the server currently has an active OAuth login flow for this provider. */
|
||||
loginInProgress?: boolean;
|
||||
/** True when an OAuth credential is stored locally but its expires timestamp is in the past — prompt the user to re-login. */
|
||||
expired?: boolean;
|
||||
/** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */
|
||||
requiresManualCode?: boolean;
|
||||
/**
|
||||
|
||||
50
packages/dashboard/app/components/OAuthReloginBanner.css
Normal file
50
packages/dashboard/app/components/OAuthReloginBanner.css
Normal file
@@ -0,0 +1,50 @@
|
||||
.oauth-relogin-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-inline-start: var(--space-xs) solid var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__message {
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__dismiss {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__dismiss:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.oauth-relogin-banner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.oauth-relogin-banner__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
129
packages/dashboard/app/components/OAuthReloginBanner.tsx
Normal file
129
packages/dashboard/app/components/OAuthReloginBanner.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { fetchAuthStatus } from "../api";
|
||||
import "./OAuthReloginBanner.css";
|
||||
|
||||
const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed";
|
||||
|
||||
function loadDismissedProviderIds(): Set<string> {
|
||||
if (typeof window === "undefined") {
|
||||
return new Set();
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(window.localStorage.getItem(DISMISS_STORAGE_KEY) ?? "[]");
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((value): value is string => typeof value === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedProviderIds(providerIds: Set<string>): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(DISMISS_STORAGE_KEY, JSON.stringify(Array.from(providerIds)));
|
||||
}
|
||||
|
||||
export function OAuthReloginBanner({
|
||||
onReLogin,
|
||||
pollIntervalMs,
|
||||
}: {
|
||||
onReLogin: (providerId?: string) => void;
|
||||
pollIntervalMs?: number;
|
||||
}): JSX.Element | null {
|
||||
const [expiredProviders, setExpiredProviders] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [dismissedProviderIds, setDismissedProviderIds] = useState<Set<string>>(() => loadDismissedProviderIds());
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const refreshAuthStatus = async () => {
|
||||
try {
|
||||
const { providers } = await fetchAuthStatus();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const nextExpiredProviders = providers
|
||||
.filter((provider) => provider.type === "oauth" && provider.expired === true)
|
||||
.map((provider) => ({ id: provider.id, name: provider.name }));
|
||||
|
||||
setExpiredProviders(nextExpiredProviders);
|
||||
setDismissedProviderIds((currentDismissed) => {
|
||||
const expiredProviderIds = new Set(nextExpiredProviders.map((provider) => provider.id));
|
||||
const filteredDismissed = new Set(
|
||||
Array.from(currentDismissed).filter((providerId) => expiredProviderIds.has(providerId)),
|
||||
);
|
||||
if (filteredDismissed.size === currentDismissed.size) {
|
||||
return currentDismissed;
|
||||
}
|
||||
persistDismissedProviderIds(filteredDismissed);
|
||||
return filteredDismissed;
|
||||
});
|
||||
} catch {
|
||||
// Non-blocking banner; ignore transient status fetch failures.
|
||||
}
|
||||
};
|
||||
|
||||
void refreshAuthStatus();
|
||||
const interval = window.setInterval(refreshAuthStatus, pollIntervalMs ?? 60 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [pollIntervalMs]);
|
||||
|
||||
const visibleExpiredProviders = useMemo(
|
||||
() => expiredProviders.filter((provider) => !dismissedProviderIds.has(provider.id)),
|
||||
[dismissedProviderIds, expiredProviders],
|
||||
);
|
||||
|
||||
if (visibleExpiredProviders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSingleProvider = visibleExpiredProviders.length === 1;
|
||||
const providerList = visibleExpiredProviders.map((provider) => provider.name).join(", ");
|
||||
|
||||
const handleDismiss = () => {
|
||||
const nextDismissed = new Set(dismissedProviderIds);
|
||||
for (const provider of visibleExpiredProviders) {
|
||||
nextDismissed.add(provider.id);
|
||||
}
|
||||
setDismissedProviderIds(nextDismissed);
|
||||
persistDismissedProviderIds(nextDismissed);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="oauth-relogin-banner" role="status" aria-live="polite">
|
||||
<div className="oauth-relogin-banner__content">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<p className="oauth-relogin-banner__message">
|
||||
{isSingleProvider
|
||||
? `Re-login required: ${providerList}. Your ${providerList} session expired — sign in again to keep agents running.`
|
||||
: `Re-login required: ${providerList}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="oauth-relogin-banner__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => onReLogin(isSingleProvider ? visibleExpiredProviders[0]?.id : undefined)}
|
||||
>
|
||||
Re-login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon oauth-relogin-banner__dismiss"
|
||||
aria-label="Dismiss OAuth re-login banner"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||
|
||||
describe("OAuthReloginBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders nothing when no providers are expired", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a banner for one expired oauth provider", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your Claude session expired/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a comma-joined list when multiple providers are expired", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onReLogin with providerId for single and undefined for multi", async () => {
|
||||
const onReLogin = vi.fn();
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={onReLogin} pollIntervalMs={1_000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
|
||||
expect(onReLogin).toHaveBeenCalledWith("claude");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
|
||||
expect(onReLogin).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("dismisses banner and stores provider ids in localStorage", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify(["claude"]));
|
||||
});
|
||||
|
||||
it("keeps banner dismissed until provider recovers then expires again", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify([]));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores expired flags on api_key and cli providers", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key", expired: true },
|
||||
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli", expired: true },
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -635,7 +635,7 @@ describe("GET /auth/status", () => {
|
||||
// Structural assertions here are about OAuth + API-key paths only.
|
||||
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
|
||||
expect(providers).toEqual([
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
]);
|
||||
@@ -657,6 +657,7 @@ describe("GET /auth/status", () => {
|
||||
name: "GitHub Copilot",
|
||||
authenticated: true,
|
||||
type: "oauth",
|
||||
expired: false,
|
||||
loginInProgress: false,
|
||||
});
|
||||
});
|
||||
@@ -679,8 +680,8 @@ describe("GET /auth/status", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
|
||||
expect(providers).toEqual([
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
|
||||
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", loginInProgress: false, requiresManualCode: true },
|
||||
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
|
||||
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true },
|
||||
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
|
||||
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
|
||||
{ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" },
|
||||
@@ -727,19 +728,36 @@ describe("GET /auth/status", () => {
|
||||
expect(res.body.providers[0].authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("treats expired oauth credentials as unauthenticated", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) =>
|
||||
provider === "github-copilot"
|
||||
? { type: "oauth", access: "token", refresh: "refresh", expires: Date.now() - 1_000 }
|
||||
: undefined,
|
||||
it("reports oauth expired flag for valid, expired, and missing credentials", async () => {
|
||||
const now = Date.now();
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "github-copilot", name: "GitHub Copilot" },
|
||||
{ id: "claude", name: "Claude" },
|
||||
{ id: "gemini-oauth", name: "Gemini OAuth" },
|
||||
]);
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(provider: string) => provider !== "gemini-oauth",
|
||||
);
|
||||
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => {
|
||||
if (provider === "github-copilot") {
|
||||
return { type: "oauth", access: "token", refresh: "refresh", expires: now + 60_000 };
|
||||
}
|
||||
if (provider === "claude") {
|
||||
return { type: "oauth", access: "token", refresh: "refresh", expires: now - 1_000 };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot");
|
||||
expect(githubCopilot.authenticated).toBe(false);
|
||||
const claude = res.body.providers.find((p: any) => p.id === "claude");
|
||||
const geminiOauth = res.body.providers.find((p: any) => p.id === "gemini-oauth");
|
||||
|
||||
expect(githubCopilot).toMatchObject({ authenticated: true, expired: false });
|
||||
expect(claude).toMatchObject({ authenticated: false, expired: true });
|
||||
expect(geminiOauth).toMatchObject({ authenticated: false, expired: false });
|
||||
});
|
||||
|
||||
it("reports loginInProgress for oauth providers with active logins", async () => {
|
||||
|
||||
@@ -248,17 +248,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
type: "oauth" | "api_key" | "cli";
|
||||
expired?: boolean;
|
||||
keyHint?: string;
|
||||
loginInProgress?: boolean;
|
||||
requiresManualCode?: boolean;
|
||||
}[] = oauthProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage),
|
||||
type: "oauth" as const,
|
||||
loginInProgress: loginInProgress.has(p.id),
|
||||
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
|
||||
}));
|
||||
}[] = oauthProviders.map((p) => {
|
||||
const hasAuth = storage.hasAuth(p.id);
|
||||
const expired = hasAuth && isExpiredOauthCredential(p.id, storage);
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: hasAuth && !expired,
|
||||
type: "oauth" as const,
|
||||
expired,
|
||||
loginInProgress: loginInProgress.has(p.id),
|
||||
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Include API-key-backed providers if supported
|
||||
if (storage.getApiKeyProviders) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ProjectEngine } from "../project-engine.js";
|
||||
import { runtimeLog } from "../logger.js";
|
||||
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
|
||||
import { NtfyNotifier } from "../notifier.js";
|
||||
import { NotificationService, OAuthExpiryMonitor } from "../notification/index.js";
|
||||
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "../notification/index.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
syncInsightExtractionAutomation: vi.fn(),
|
||||
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({
|
||||
notificationServiceStop: vi.fn(),
|
||||
oauthExpiryMonitorStart: vi.fn(async () => undefined),
|
||||
oauthExpiryMonitorStop: vi.fn(),
|
||||
oauthValidityLoggerStart: vi.fn(async () => undefined),
|
||||
oauthValidityLoggerStop: vi.fn(),
|
||||
runtimeConfigurePrMonitoring: vi.fn(),
|
||||
prHandlerCreateFollowUpTask: vi.fn(async () => undefined),
|
||||
}));
|
||||
@@ -98,6 +100,10 @@ vi.mock("../notification/index.js", () => ({
|
||||
start: mocks.oauthExpiryMonitorStart,
|
||||
stop: mocks.oauthExpiryMonitorStop,
|
||||
})),
|
||||
OAuthValidityLogger: vi.fn().mockImplementation(() => ({
|
||||
start: mocks.oauthValidityLoggerStart,
|
||||
stop: mocks.oauthValidityLoggerStop,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../auth-storage.js", () => ({
|
||||
@@ -261,6 +267,8 @@ beforeEach(() => {
|
||||
mocks.notificationServiceStop.mockClear();
|
||||
mocks.oauthExpiryMonitorStart.mockClear();
|
||||
mocks.oauthExpiryMonitorStop.mockClear();
|
||||
mocks.oauthValidityLoggerStart.mockClear();
|
||||
mocks.oauthValidityLoggerStop.mockClear();
|
||||
|
||||
mocks.execFile.mockImplementation((
|
||||
_file: string,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OAuthValidityLogger } from "../oauth-validity-logger.js";
|
||||
import type { AuthStorageLike } from "../oauth-expiry-monitor.js";
|
||||
|
||||
function createAuthStorage(providers: Array<{ id: string; name: string }>, credentials: Record<string, any>): AuthStorageLike {
|
||||
return {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: () => providers,
|
||||
get: (providerId: string) => credentials[providerId],
|
||||
};
|
||||
}
|
||||
|
||||
describe("OAuthValidityLogger", () => {
|
||||
it("logs one line per expired oauth credential on start", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage = createAuthStorage(
|
||||
[
|
||||
{ id: "openai-codex", name: "OpenAI Codex" },
|
||||
{ id: "claude", name: "Claude" },
|
||||
],
|
||||
{
|
||||
"openai-codex": { type: "oauth", expires: now - 1_000 },
|
||||
claude: { type: "oauth", expires: now - 500 },
|
||||
},
|
||||
);
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
|
||||
expect(logger).toHaveBeenCalledTimes(2);
|
||||
validityLogger.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("logs again on interval without dedupe", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage = createAuthStorage(
|
||||
[{ id: "openai-codex", name: "OpenAI Codex" }],
|
||||
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
|
||||
);
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(logger).toHaveBeenCalledTimes(2);
|
||||
validityLogger.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not log for valid oauth, api key, or missing expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage = createAuthStorage(
|
||||
[
|
||||
{ id: "valid-oauth", name: "Valid OAuth" },
|
||||
{ id: "api-key-provider", name: "API Key" },
|
||||
{ id: "missing-expiry", name: "Missing Expiry" },
|
||||
],
|
||||
{
|
||||
"valid-oauth": { type: "oauth", expires: now + 10_000 },
|
||||
"api-key-provider": { type: "api_key" },
|
||||
"missing-expiry": { type: "oauth" },
|
||||
},
|
||||
);
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
|
||||
expect(logger).not.toHaveBeenCalled();
|
||||
validityLogger.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stop cancels the interval", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage = createAuthStorage(
|
||||
[{ id: "openai-codex", name: "OpenAI Codex" }],
|
||||
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
|
||||
);
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
validityLogger.stop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(logger).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("continues iterating when one provider throws", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage: AuthStorageLike = {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: () => [
|
||||
{ id: "broken", name: "Broken" },
|
||||
{ id: "claude", name: "Claude" },
|
||||
],
|
||||
get: (providerId: string) => {
|
||||
if (providerId === "broken") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return { type: "oauth", expires: now - 100 };
|
||||
},
|
||||
};
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
|
||||
expect(logger).toHaveBeenCalledTimes(1);
|
||||
expect(logger).toHaveBeenCalledWith(
|
||||
"oauth credential expired — provider re-login required",
|
||||
expect.objectContaining({ providerId: "claude" }),
|
||||
);
|
||||
validityLogger.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("never includes token material in log metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const logger = vi.fn();
|
||||
const authStorage = createAuthStorage(
|
||||
[{ id: "openai-codex", name: "OpenAI Codex" }],
|
||||
{
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
expires: now - 1_000,
|
||||
accessToken: "secret-access",
|
||||
refreshToken: "secret-refresh",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
|
||||
await validityLogger.start();
|
||||
|
||||
const [, meta] = logger.mock.calls[0] ?? [];
|
||||
expect(Object.keys(meta ?? {}).sort()).toEqual(["expiresAt", "providerId", "providerName"]);
|
||||
validityLogger.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -9,3 +9,5 @@ export type { NotificationServiceOptions } from "./notification-service.js";
|
||||
|
||||
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";
|
||||
|
||||
71
packages/engine/src/notification/oauth-validity-logger.ts
Normal file
71
packages/engine/src/notification/oauth-validity-logger.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { schedulerLog } from "../logger.js";
|
||||
import type { AuthStorageLike } from "./oauth-expiry-monitor.js";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface OAuthValidityLoggerOptions {
|
||||
authStorage: AuthStorageLike;
|
||||
intervalMs?: number;
|
||||
clock?: () => number;
|
||||
logger?: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export class OAuthValidityLogger {
|
||||
private readonly intervalMs: number;
|
||||
private readonly clock: () => number;
|
||||
private readonly logger: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly opts: OAuthValidityLoggerOptions) {
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
this.clock = opts.clock ?? Date.now;
|
||||
this.logger = opts.logger ?? ((message, meta) => schedulerLog.warn(message, meta));
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.check();
|
||||
this.timer = setInterval(() => {
|
||||
void this.check();
|
||||
}, this.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.timer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
this.opts.authStorage.reload?.();
|
||||
const providers = this.opts.authStorage.getOAuthProviders?.() ?? [];
|
||||
const now = this.clock();
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const credential = this.opts.authStorage.get?.(provider.id);
|
||||
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
|
||||
continue;
|
||||
}
|
||||
if (credential.expires > now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger("oauth credential expired — provider re-login required", {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
expiresAt: new Date(credential.expires).toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
schedulerLog.warn(`OAuth validity logger failed for provider=${provider.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type { ProjectRuntimeConfig } from "./project-runtime.js";
|
||||
import { PrMonitor } from "./pr-monitor.js";
|
||||
import { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
import { NtfyNotifier } from "./notifier.js";
|
||||
import { NotificationService, OAuthExpiryMonitor } from "./notification/index.js";
|
||||
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js";
|
||||
import type { NotificationChatStore } from "./notification/notification-service.js";
|
||||
import { GridlockDetector } from "./gridlock-detector.js";
|
||||
import { createFusionAuthStorage } from "./auth-storage.js";
|
||||
@@ -177,6 +177,7 @@ export class ProjectEngine {
|
||||
private notifier?: NtfyNotifier;
|
||||
private notificationService?: NotificationService;
|
||||
private oauthExpiryMonitor?: OAuthExpiryMonitor;
|
||||
private oauthValidityLogger?: OAuthValidityLogger;
|
||||
private gridlockDetector?: GridlockDetector;
|
||||
private cronRunner?: CronRunner;
|
||||
private automationStore?: AutomationStoreType;
|
||||
@@ -353,11 +354,14 @@ export class ProjectEngine {
|
||||
agentNameResolver,
|
||||
});
|
||||
await this.notificationService.start();
|
||||
const authStorage = createFusionAuthStorage();
|
||||
this.oauthExpiryMonitor = new OAuthExpiryMonitor({
|
||||
authStorage: createFusionAuthStorage(),
|
||||
authStorage,
|
||||
notificationService: this.notificationService,
|
||||
});
|
||||
await this.oauthExpiryMonitor.start();
|
||||
this.oauthValidityLogger = new OAuthValidityLogger({ authStorage });
|
||||
await this.oauthValidityLogger.start();
|
||||
|
||||
// Backward-compatibility shim for gridlock notifications.
|
||||
this.notifier = new NtfyNotifier(
|
||||
@@ -581,6 +585,7 @@ export class ProjectEngine {
|
||||
|
||||
// Stop auxiliary subsystems
|
||||
this.oauthExpiryMonitor?.stop();
|
||||
this.oauthValidityLogger?.stop();
|
||||
this.notificationService?.stop();
|
||||
this.notifier?.stop();
|
||||
this.gridlockDetector?.stop();
|
||||
|
||||
Reference in New Issue
Block a user