diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 1dc3091f16..741758a7d9 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -107,6 +107,9 @@ const EnvironmentSchema = z SMTP_PASSWORD: z.string().optional(), PLAIN_API_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_SECRET: z.string().optional(), + PLAIN_CUSTOMER_CARDS_KEY: z.string().optional(), + PLAIN_CUSTOMER_CARDS_HEADERS: z.string().optional(), WORKER_SCHEMA: z.string().default("graphile_worker"), WORKER_CONCURRENCY: z.coerce.number().int().default(10), WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb818002..66f12fb9ff 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -21,9 +21,12 @@ import { } from "~/components/primitives/Table"; import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; -import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; -import { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; +import { + validateAndConsumeImpersonationToken, +} from "~/services/impersonation.server"; import { createSearchParams } from "~/utils/searchParams"; +import { logger } from "~/services/logger.server"; export const SearchParams = z.object({ page: z.coerce.number().optional(), @@ -32,7 +35,44 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; +const FormSchema = z.object({ id: z.string() }); + +async function handleImpersonationRequest( + request: Request, + userId: string +): Promise { + const user = await requireUser(request); + if (!user.admin) { + return redirect("/"); + } + return redirectWithImpersonation(request, userId, "/"); +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { + // Check if this is an impersonation request via query parameter (e.g., from Plain customer cards) + const url = new URL(request.url); + const impersonateUserId = url.searchParams.get("impersonate"); + const impersonationToken = url.searchParams.get("impersonationToken"); + + if (impersonateUserId) { + // Require both userId and token for GET-based impersonation + if (!impersonationToken) { + logger.warn("Impersonation request missing token"); + return redirect("/"); + } + + // Validate and consume the token (prevents replay attacks) + const validatedUserId = await validateAndConsumeImpersonationToken(impersonationToken); + + if (!validatedUserId || validatedUserId !== impersonateUserId) { + logger.warn("Invalid or expired impersonation token"); + return redirect("/"); + } + + return handleImpersonationRequest(request, impersonateUserId); + } + + // Normal loader logic for admin dashboard const userId = await requireUserId(request); const searchParams = createSearchParams(request.url, SearchParams); @@ -44,8 +84,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); }; -const FormSchema = z.object({ id: z.string() }); - export async function action({ request }: ActionFunctionArgs) { if (request.method.toLowerCase() !== "post") { return new Response("Method not allowed", { status: 405 }); @@ -54,12 +92,12 @@ export async function action({ request }: ActionFunctionArgs) { const payload = Object.fromEntries(await request.formData()); const { id } = FormSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); + return handleImpersonationRequest(request, id); } export default function AdminDashboardRoute() { const user = useUser(); - const { users, filters, page, pageCount } = useTypedLoaderData(); + const { users, filters, page, pageCount } = useTypedLoaderData() as any; return (
No users found for search ) : ( - users.map((user) => { + users.map((user: (typeof users)[0]) => { return ( - {user.orgMemberships.map((org) => ( + {user.orgMemberships.map((org: (typeof user.orgMemberships)[0]) => ( data.email || data.externalId, + { + message: "Either customer.email or customer.externalId must be provided", + path: ["customer"], + } + ), + thread: z + .object({ + id: z.string(), + }) + .optional(), +}); + +function sanitizeHeaders(request: Request, skipHeaders?: string[]): Partial> { + const authHeaderName = (env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization").toLowerCase(); + const defaultSkipHeaders = skipHeaders || [authHeaderName, "cookie"]; + const sanitizedHeaders: Partial> = {}; + + for (const [key, value] of request.headers.entries()) { + if (!defaultSkipHeaders.includes(key.toLowerCase())) { + sanitizedHeaders[key] = value; + } + } + + return sanitizedHeaders; +} + +// Authenticate the request from Plain +function authenticatePlainRequest(request: Request): boolean { + const authHeaderName = env.PLAIN_CUSTOMER_CARDS_HEADERS || "Authorization"; + const authHeader = request.headers.get(authHeaderName); + const expectedSecret = env.PLAIN_CUSTOMER_CARDS_SECRET; + if (!expectedSecret) { + logger.warn("PLAIN_CUSTOMER_CARDS_SECRET not configured"); + return false; + } + + if (!authHeader) { + return false; + } + + // Support both "Bearer " and plain token formats + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader; + + // Use constant-time comparison to prevent timing attacks + const encoder = new TextEncoder(); + const tokenBuffer = encoder.encode(token); + const secretBuffer = encoder.encode(expectedSecret); + if (tokenBuffer.byteLength !== secretBuffer.byteLength) { + return false; + } + return timingSafeEqual(tokenBuffer, secretBuffer); +} + +export async function action({ request }: ActionFunctionArgs) { + // Only accept POST requests + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + // Authenticate the request + if (!authenticatePlainRequest(request)) { + logger.warn("Unauthorized Plain customer card request", { + headers: sanitizeHeaders(request), + }); + return json({ error: "Unauthorized" }, { status: 401 }); + } + + // Parse JSON with separate error handling for malformed JSON + let body: unknown; + try { + body = await request.json(); + } catch (error) { + // Handle JSON parsing errors as client errors (400) instead of server errors (500) + if (error instanceof SyntaxError || error instanceof TypeError) { + logger.warn("Malformed JSON in Plain customer card request", { + error: error.message, + }); + return json({ error: "Invalid JSON in request body" }, { status: 400 }); + } + // Re-throw unexpected errors to be caught by outer catch + throw error; + } + + // Validate the request body schema + const parsed = PlainCustomerCardRequestSchema.safeParse(body); + + if (!parsed.success) { + logger.warn("Invalid Plain customer card request", { + errors: parsed.error.errors, + body, + }); + return json({ error: "Invalid request body" }, { status: 400 }); + } + + try { + + const { customer, cardKeys } = parsed.data; + + // Look up the user by externalId (which is User.id) + let user = null; + if (customer.externalId) { + user = await prisma.user.findUnique({ + where: { id: customer.externalId }, + include: { + orgMemberships: { + where: { + organization: { deletedAt: null }, + }, + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, // Limit to recent projects + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } else if (customer.email) { + // Fallback to email lookup if externalId is not provided + user = await prisma.user.findUnique({ + where: { email: customer.email }, + include: { + orgMemberships: { + where: { + organization: { deletedAt: null }, + }, + include: { + organization: { + include: { + projects: { + where: { deletedAt: null }, + take: 10, + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + }, + }); + } + + // If user not found, return empty cards + if (!user) { + logger.info("User not found for Plain customer card request", { + customerId: customer.id, + externalId: customer.externalId, + email: customer.email, + }); + return json({ cards: [] }); + } + + // Build cards based on requested cardKeys + const cards = []; + + const accountDetailsKey = env.PLAIN_CUSTOMER_CARDS_KEY || "account-details"; + for (const cardKey of cardKeys) { + switch (cardKey) { + case accountDetailsKey: { + // Generate a signed one-time token for impersonation + const impersonationToken = await generateImpersonationToken(user.id); + // Build the impersonate URL with token for CSRF protection + const impersonateUrl = `${env.APP_ORIGIN}/admin?impersonate=${user.id}&impersonationToken=${encodeURIComponent(impersonationToken)}`; + + cards.push({ + key: accountDetailsKey, + timeToLiveSeconds: 15, + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Account Details", + size: "L", + color: "NORMAL", + }), + uiComponent.spacer({ size: "M" }), + uiComponent.row({ + mainContent: [ + uiComponent.text({ + text: "User ID", + size: "S", + color: "MUTED", + }), + ], + asideContent: [ + uiComponent.copyButton({ + value: user.id, + tooltip: "Copy", + }), + ], + }), + uiComponent.spacer({ size: "S" }), + uiComponent.row({ + mainContent: [ + uiComponent.text({ + text: "Email", + size: "S", + color: "MUTED", + }), + ], + asideContent: [ + uiComponent.text({ + text: user.email, + size: "S", + color: "NORMAL", + }), + ], + }), + uiComponent.spacer({ size: "S" }), + uiComponent.row({ + mainContent: [ + uiComponent.text({ + text: "Name", + size: "S", + color: "MUTED", + }), + ], + asideContent: [ + uiComponent.text({ + text: user.name || user.displayName || "N/A", + size: "S", + color: "NORMAL", + }), + ], + }), + uiComponent.spacer({ size: "S" }), + uiComponent.row({ + mainContent: [ + uiComponent.text({ + text: "Member Since", + size: "S", + color: "MUTED", + }), + ], + asideContent: [ + uiComponent.text({ + text: new Date(user.createdAt).toLocaleDateString(), + size: "S", + color: "NORMAL", + }), + ], + }), + uiComponent.spacer({ size: "M" }), + uiComponent.divider({ spacingSize: "M" }), + uiComponent.spacer({ size: "M" }), + uiComponent.linkButton({ + label: "Impersonate User", + url: impersonateUrl, + }), + ], + }), + ], + }); + break; + } + + case "organizations": { + if (user.orgMemberships.length === 0) { + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Organizations", + size: "L", + color: "NORMAL", + }), + uiComponent.spacer({ size: "M" }), + uiComponent.text({ + text: "No organizations found", + size: "S", + color: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const orgComponents = user.orgMemberships.flatMap( + ( + membership: (typeof user.orgMemberships)[0], + index: number + ) => { + const org = membership.organization; + const projectCount = org.projects.length; + + return [ + ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), + uiComponent.text({ + text: org.title, + size: "M", + color: "NORMAL", + }), + uiComponent.spacer({ size: "XS" }), + uiComponent.row({ + mainContent: [ + uiComponent.badge({ + label: membership.role, + color: membership.role === "ADMIN" ? "BLUE" : "GREY", + }), + ], + asideContent: [ + uiComponent.text({ + text: `${projectCount} recent project${projectCount !== 1 ? "s" : ""}`, + size: "S", + color: "MUTED", + }), + ], + }), + uiComponent.spacer({ size: "XS" }), + uiComponent.linkButton({ + label: "View in Dashboard", + url: `${env.APP_ORIGIN}/@/orgs/${org.slug}`, + }), + ]; + } + ); + + cards.push({ + key: "organizations", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Organizations", + size: "L", + color: "NORMAL", + }), + uiComponent.spacer({ size: "M" }), + ...orgComponents, + ], + }), + ], + }); + break; + } + + case "projects": { + const allProjects = user.orgMemberships.flatMap((membership) => + membership.organization.projects.map((project) => ({ + ...project, + orgSlug: membership.organization.slug, + })) + ); + + if (allProjects.length === 0) { + cards.push({ + key: "projects", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Projects", + size: "L", + color: "NORMAL", + }), + uiComponent.spacer({ size: "M" }), + uiComponent.text({ + text: "No projects found", + size: "S", + color: "MUTED", + }), + ], + }), + ], + }); + break; + } + + const projectComponents = allProjects.slice(0, 10).flatMap( + ( + project: typeof allProjects[0] & { orgSlug: string }, + index: number + ) => { + return [ + ...(index > 0 ? [uiComponent.divider({ spacingSize: "M" })] : []), + uiComponent.text({ + text: project.name, + size: "M", + color: "NORMAL", + }), + uiComponent.spacer({ size: "XS" }), + uiComponent.row({ + mainContent: [ + uiComponent.badge({ + label: project.version, + color: project.version === "V3" ? "GREEN" : "GREY", + }), + ], + asideContent: [ + uiComponent.linkButton({ + label: "View", + url: `${env.APP_ORIGIN}/orgs/${project.orgSlug}/projects/${project.slug}`, + }), + ], + }), + ]; + } + ); + + cards.push({ + key: "projects", + timeToLiveSeconds: 300, + components: [ + uiComponent.container({ + content: [ + uiComponent.text({ + text: "Projects", + size: "L", + color: "NORMAL", + }), + uiComponent.spacer({ size: "M" }), + ...projectComponents, + ], + }), + ], + }); + break; + } + + default: + // Unknown card key - skip it + logger.info("Unknown card key requested", { cardKey }); + break; + } + } + + return json({ cards }); + } catch (error) { + logger.error("Error processing Plain customer card request", { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + return json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index dee0a889d1..8f40da009a 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -51,6 +51,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ "/api/v1/authorization-code", "/api/v1/token", "/api/v1/usage/ingest", + "/api/v1/plain/customer-cards", /^\/api\/v1\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/tasks/$id/callback/$secret /^\/api\/v1\/runs\/[^\/]+\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/runs/$runId/tasks/$id/callback/$secret /^\/api\/v1\/http-endpoints\/[^\/]+\/env\/[^\/]+\/[^\/]+$/, // /api/v1/http-endpoints/$httpEndpointId/env/$envType/$shortcode diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index 78c771528b..4a3a676d1a 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -1,5 +1,9 @@ import { createCookieSessionStorage, type Session } from "@remix-run/node"; +import { SignJWT, jwtVerify, errors } from "jose"; +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: { @@ -42,3 +46,89 @@ export async function clearImpersonationId(request: Request) { 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 { + 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") + .sign(secret); + + return token; +} + +/** + * Validate and consume an impersonation token (prevents replay attacks) + */ +export async function validateAndConsumeImpersonationToken( + token: string +): Promise { + 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; + } + + // Check if token has already been used (prevent replay attacks) + const redis = getImpersonationTokenRedisClient(); + const tokenKey = token; + + // Try to set the key with NX (only if not exists) and expiration + // This atomically marks the token as used + const result = await redis.set(tokenKey, "1", "EX", IMPERSONATION_TOKEN_EXPIRY_SECONDS, "NX"); + + if (result !== "OK") { + // Token was already used + return undefined; + } + + 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; + } +}