import { json, redirect } from "@remix-run/node"; import { tryCatch } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; import { createRedisClient } from "~/redis.server"; import { singleton } from "~/utils/singleton"; import { ssoSessionExpiredLogoutPath } from "~/utils/ssoSession"; import type { AuthUser } from "./authUser"; import { logger } from "./logger.server"; import { ssoController } from "./sso.server"; // Dedicated Redis client for the single-flight throttle. Reuses the // shared REDIS_* connection (same wiring the other simple shared-state // services use). const redis = singleton("ssoRevalidationRedis", () => createRedisClient("trigger:ssoRevalidation", { host: env.REDIS_HOST, port: env.REDIS_PORT, username: env.REDIS_USERNAME, password: env.REDIS_PASSWORD, tlsDisabled: env.REDIS_TLS_DISABLED === "true", }) ); function revalidationKey(userId: string): string { return `sso:reval:${userId}`; } // Module-scoped so it's a unique symbol — lets the Promise.race result be // narrowed cleanly between "timed out" and the plugin's Result. const REVALIDATION_TIMEOUT = Symbol("sso-revalidation-timeout"); /** * Periodically re-validate an SSO-established session against the IdP. * * Called from the session read path on every authenticated request, but: * - returns immediately unless the SSO feature is enabled AND the * session carries the `sso` marker (non-SSO sessions pay nothing — no * Redis round-trip); * - is single-flight via a Redis `SET key 1 NX EX `: only the * first request per interval window actually calls the SSO plugin, * concurrent requests see the key and skip; * - fails OPEN — any error (Redis or the plugin) keeps the session * alive. Only an explicit `{ valid: false }` triggers logout. * * Throws `redirect("/logout")` when the session is confirmed invalid, * mirroring how `maybeAutoLogout` terminates a session from this path. */ export async function revalidateSsoSession( request: Request, authUser: AuthUser | null | undefined ): Promise { // Deploy gate + SSO-session gate. if (!env.SSO_ENABLED) return; if (!authUser?.sso) return; // Never revalidate on /logout itself — the loader there must be allowed // to destroy the cookie rather than redirect in a loop. if (new URL(request.url).pathname === "/logout") return; const interval = env.SSO_SESSION_REVALIDATION_INTERVAL_SECONDS; const key = revalidationKey(authUser.userId); // Single-flight: acquire the window. Only the request that sets the // key (NX) proceeds to the actual check; everyone else this window // treats the session as valid. const [setError, acquired] = await tryCatch(redis.set(key, "1", "EX", interval, "NX")); if (setError) { // Redis unavailable → fail-open, don't block the request. logger.warn("SSO revalidation: redis SET NX failed; skipping", { error: setError }); return; } if (acquired !== "OK") return; // Hard 2s (env-configurable) timeout on the plugin round-trip so a slow // or hung SSO dependency can never block the request. On timeout we fail // OPEN (keep the session + the throttle key) and emit a stable // `sso.revalidation.timeout` warn for alerting. const timeoutMs = env.SSO_SESSION_REVALIDATION_TIMEOUT_MS; let timer: ReturnType | undefined; let result: | Awaited> | typeof REVALIDATION_TIMEOUT; try { result = await Promise.race([ // ResultAsync is a PromiseLike; Promise.resolve unwraps it to a Result. Promise.resolve( ssoController.validateSession({ userId: authUser.userId, idpOrgId: authUser.sso.idpOrgId, connectionId: authUser.sso.connectionId, }) ), new Promise((resolve) => { timer = setTimeout(() => resolve(REVALIDATION_TIMEOUT), timeoutMs); }), ]); } catch (error) { // A ResultAsync resolves to an Err rather than rejecting, but guard // against a synchronous throw / rejected promise from the plugin all // the same — fail OPEN (keep the session + the throttle key) exactly // like the Err branch below. if (timer) clearTimeout(timer); logger.warn("SSO revalidation threw; failing open (session kept alive)", { userId: authUser.userId, error, }); return; } if (timer) clearTimeout(timer); if (result === REVALIDATION_TIMEOUT) { logger.warn("SSO revalidation timed out; failing open (session kept alive)", { event: "sso.revalidation.timeout", userId: authUser.userId, timeoutMs, }); return; } if (result.isErr()) { // Fail-open: keep the session, and keep the throttle key so we don't // hammer the plugin while the dependency is unhealthy. logger.warn("SSO revalidation errored; failing open (session kept alive)", { userId: authUser.userId, reason: result.error, }); return; } if (result.value.valid) return; // still valid — TTL governs the next check // Confirmed invalid. Clear the throttle so other tabs/requests for this // user re-check (and log out) on their next request instead of waiting // for the TTL, then terminate this session. try { await redis.del(key); } catch { // best-effort; the key expires on its own anyway } logger.info("SSO revalidation: session invalid, logging out", { userId: authUser.userId, }); // Navigations (and Remix data requests, which the client follows) get the // logout redirect. Programmatic/API fetches can't follow a 302-to-HTML, so // they get a plain 401; the session is re-checked and the user is redirected // on their next navigation/refresh. const url = new URL(request.url); const isRemixDataRequest = url.searchParams.has("_data"); const dest = request.headers.get("sec-fetch-dest"); const isDocumentRequest = dest ? dest === "document" : (request.headers.get("accept") ?? "").includes("text/html"); if (isRemixDataRequest || isDocumentRequest) { throw redirect(ssoSessionExpiredLogoutPath()); } throw json({ error: "sso_session_invalidated" }, { status: 401 }); }