159 lines
6.1 KiB
TypeScript
159 lines
6.1 KiB
TypeScript
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 <interval>`: 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<void> {
|
|
// 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<typeof setTimeout> | undefined;
|
|
let result:
|
|
| Awaited<ReturnType<typeof ssoController.validateSession>>
|
|
| 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<typeof REVALIDATION_TIMEOUT>((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 });
|
|
}
|