167 lines
5.1 KiB
JavaScript
167 lines
5.1 KiB
JavaScript
// Background proactive OAuth token refresh — independent of inbound requests.
|
|
// Fail-open everywhere: tick errors and per-connection failures never kill the interval.
|
|
|
|
import * as log from "../utils/logger.js";
|
|
import { getRefreshLeadMs } from "open-sse/services/tokenRefresh.js";
|
|
import { getCredentialExpiryMs } from "open-sse/services/oauthCredentialManager.js";
|
|
|
|
/** Refresh when expiry is within 30 minutes (or the provider on-request lead, whichever larger). */
|
|
export const BACKGROUND_REFRESH_LEAD_MS = 30 * 60 * 1000;
|
|
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
const INITIAL_DELAY_MS = 10 * 1000;
|
|
|
|
let started = false;
|
|
let intervalHandle = null;
|
|
let initialTimeoutHandle = null;
|
|
let tickRunning = false;
|
|
|
|
function isTruthyEnv(value) {
|
|
if (value == null || value === "") return false;
|
|
const v = String(value).trim().toLowerCase();
|
|
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
}
|
|
|
|
function isNonServerRuntime() {
|
|
if (typeof window !== "undefined") return true;
|
|
const phase = process.env.NEXT_PHASE || "";
|
|
if (
|
|
phase === "phase-production-build" ||
|
|
phase === "phase-export" ||
|
|
phase === "phase-static"
|
|
) {
|
|
return true;
|
|
}
|
|
// Next.js build / static generation markers
|
|
if (process.env.NEXT_RUNTIME === "edge") return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Pure selection: OAuth connections with a refreshToken whose access token
|
|
* expires within max(provider on-request lead, BACKGROUND_REFRESH_LEAD_MS).
|
|
*
|
|
* @param {Array<object>} connections
|
|
* @param {number} [nowMs]
|
|
* @returns {Array<object>}
|
|
*/
|
|
export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now()) {
|
|
if (!Array.isArray(connections) || connections.length === 0) return [];
|
|
|
|
const out = [];
|
|
for (const conn of connections) {
|
|
if (!conn) continue;
|
|
|
|
const authType = String(conn.authType || "").toLowerCase().replace(/_/g, "");
|
|
if (authType !== "oauth") continue;
|
|
if (!conn.refreshToken) continue;
|
|
|
|
const expiresAtMs = getCredentialExpiryMs(conn);
|
|
if (expiresAtMs === null) continue;
|
|
|
|
const providerLead = getRefreshLeadMs(conn.provider);
|
|
const leadMs = Math.max(
|
|
Number.isFinite(providerLead) ? providerLead : 0,
|
|
BACKGROUND_REFRESH_LEAD_MS
|
|
);
|
|
|
|
if (expiresAtMs - nowMs < leadMs) {
|
|
out.push(conn);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function loadActiveConnections() {
|
|
// Dynamic import avoids circular load with db / app graph at module eval time.
|
|
const { getProviderConnections } = await import("../../lib/db/repos/connectionsRepo.js");
|
|
return getProviderConnections({ isActive: true });
|
|
}
|
|
|
|
async function refreshOne(connection) {
|
|
const { checkAndRefreshToken } = await import("./tokenRefresh.js");
|
|
return checkAndRefreshToken(connection.provider, connection, { force: true });
|
|
}
|
|
|
|
/**
|
|
* One scheduler tick. Fail-open at top level and per connection.
|
|
* @param {{ loadConnections?: Function, refreshConnection?: Function }} [deps]
|
|
*/
|
|
export async function runBackgroundTokenRefreshTick(deps = {}) {
|
|
if (tickRunning) return;
|
|
tickRunning = true;
|
|
try {
|
|
const load = deps.loadConnections || loadActiveConnections;
|
|
const refresh = deps.refreshConnection || refreshOne;
|
|
|
|
const connections = await load();
|
|
const due = selectConnectionsNeedingRefresh(connections, Date.now());
|
|
|
|
if (due.length === 0) return;
|
|
|
|
await Promise.allSettled(
|
|
due.map(async (conn) => {
|
|
try {
|
|
await refresh(conn);
|
|
} catch (err) {
|
|
log.warn("BG_TOKEN_REFRESH", "Connection refresh failed (swallowed)", {
|
|
id: conn?.id,
|
|
provider: conn?.provider,
|
|
error: err?.message ?? String(err),
|
|
});
|
|
}
|
|
})
|
|
);
|
|
} catch (err) {
|
|
log.warn("BG_TOKEN_REFRESH", "Tick failed (swallowed)", {
|
|
error: err?.message ?? String(err),
|
|
});
|
|
} finally {
|
|
tickRunning = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the background interval. Safe to call multiple times (no-op if already started).
|
|
* @param {{ intervalMs?: number }} [opts]
|
|
* @returns {boolean} true if started this call
|
|
*/
|
|
export function startBackgroundTokenRefresh({ intervalMs } = {}) {
|
|
if (started) return false;
|
|
if (isTruthyEnv(process.env.DISABLE_BACKGROUND_TOKEN_REFRESH)) return false;
|
|
if (isNonServerRuntime()) return false;
|
|
|
|
started = true;
|
|
const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS;
|
|
|
|
const safeTick = () => {
|
|
runBackgroundTokenRefreshTick().catch((err) => {
|
|
log.warn("BG_TOKEN_REFRESH", "Unhandled tick rejection (swallowed)", {
|
|
error: err?.message ?? String(err),
|
|
});
|
|
});
|
|
};
|
|
|
|
// First pass soon after boot so idle connections don't wait a full interval.
|
|
initialTimeoutHandle = setTimeout(safeTick, INITIAL_DELAY_MS);
|
|
if (initialTimeoutHandle.unref) initialTimeoutHandle.unref();
|
|
|
|
intervalHandle = setInterval(safeTick, period);
|
|
if (intervalHandle.unref) intervalHandle.unref();
|
|
|
|
return true;
|
|
}
|
|
|
|
export function stopBackgroundTokenRefresh() {
|
|
if (initialTimeoutHandle) {
|
|
clearTimeout(initialTimeoutHandle);
|
|
initialTimeoutHandle = null;
|
|
}
|
|
if (intervalHandle) {
|
|
clearInterval(intervalHandle);
|
|
intervalHandle = null;
|
|
}
|
|
if (started) {
|
|
started = false;
|
|
}
|
|
}
|