Files
2026-07-13 13:32:57 +08:00

155 lines
4.9 KiB
TypeScript

import { createCookieSessionStorage, type Session } from "@remix-run/node";
import { SignJWT, jwtVerify, errors } from "jose";
import { randomUUID } from "node:crypto";
import { singleton } from "~/utils/singleton";
import { createRedisClient, type RedisClient } from "~/redis.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
export const impersonationSessionStorage = createCookieSessionStorage({
cookie: {
name: "__impersonate", // use any name you want here
sameSite: "lax", // this helps with CSRF
path: "/", // remember to add this so the cookie will work in all routes
httpOnly: true, // for security reasons, make this cookie http only
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production", // enable this in prod only
maxAge: 60 * 60 * 24, // 1 day
},
});
export function getImpersonationSession(request: Request) {
return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
}
export function commitImpersonationSession(session: Session) {
return impersonationSessionStorage.commitSession(session);
}
export async function getImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
return session.get("impersonatedUserId") as string | undefined;
}
export async function setImpersonationId(userId: string, request: Request) {
const session = await getImpersonationSession(request);
session.set("impersonatedUserId", userId);
return session;
}
export async function clearImpersonationId(request: Request) {
const session = await getImpersonationSession(request);
session.unset("impersonatedUserId");
return session;
}
// Impersonation token utilities for CSRF protection
const IMPERSONATION_TOKEN_EXPIRY_SECONDS = 5 * 60; // 5 minutes
function getImpersonationTokenSecret(): Uint8Array {
return new TextEncoder().encode(env.SESSION_SECRET);
}
function getImpersonationTokenRedisClient(): RedisClient {
return singleton("impersonationTokenRedis", () =>
createRedisClient("impersonation:token", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
keyPrefix: "impersonation:token:",
})
);
}
/**
* Generate a signed one-time impersonation token for a user
*/
export async function generateImpersonationToken(userId: string): Promise<string> {
const secret = getImpersonationTokenSecret();
const now = Math.floor(Date.now() / 1000);
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(now + IMPERSONATION_TOKEN_EXPIRY_SECONDS)
.setIssuer("https://trigger.dev")
.setAudience("https://trigger.dev/admin")
.setJti(randomUUID())
.sign(secret);
return token;
}
/**
* Validate and consume an impersonation token (prevents replay attacks)
*/
export async function validateAndConsumeImpersonationToken(
token: string
): Promise<string | undefined> {
try {
const secret = getImpersonationTokenSecret();
// Verify the token signature and expiration
const { payload } = await jwtVerify(token, secret, {
issuer: "https://trigger.dev",
audience: "https://trigger.dev/admin",
});
const userId = payload.userId as string | undefined;
if (!userId || typeof userId !== "string") {
return undefined;
}
// Atomically mark the token as used to prevent replay attacks.
// Use the jti (a short UUID) as the Redis key rather than the full JWT string.
// If Redis is unavailable, fall back to JWT expiry as the only protection.
if (env.CACHE_REDIS_HOST) {
// Defensively reject tokens without a jti (e.g. issued before this change)
if (!payload.jti) {
logger.warn("Impersonation token missing jti claim, rejecting for safety");
return undefined;
}
try {
const redis = getImpersonationTokenRedisClient();
const result = await redis.set(
payload.jti,
"1",
"EX",
IMPERSONATION_TOKEN_EXPIRY_SECONDS,
"NX"
);
if (result !== "OK") {
// Token was already used
return undefined;
}
} catch (redisError) {
logger.warn(
"Redis unavailable for impersonation token tracking, relying on JWT expiry only",
{
error: redisError instanceof Error ? redisError.message : String(redisError),
}
);
}
}
return userId;
} catch (error) {
if (error instanceof errors.JWTExpired || error instanceof errors.JWTInvalid) {
return undefined;
}
logger.error("Error validating impersonation token", {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}