Submatter/apps/web/app/api/chat/ticket/route.ts

78 lines
2.7 KiB
TypeScript
Raw Normal View History

import { createHmac } from "node:crypto";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const input = z.object({ workspace: z.string().min(1).max(200) }).strict();
const noStore = { "Cache-Control": "no-store" };
function trustedOrigin(request: Request): string | null {
// Docker's request URL may be internal. Pin production to deployment configuration,
// not Host/forwarded headers that a caller could supply outside the trusted proxy.
const configured = process.env.AUTH_URL || process.env.NEXTAUTH_URL;
if (!configured && process.env.NODE_ENV === "production") return null;
const parsed = z
.string()
.url()
.safeParse(configured || request.url);
if (!parsed.success) return null;
const url = new URL(parsed.data);
if (
!["https:", "http:"].includes(url.protocol) ||
url.username ||
url.password
)
return null;
return url.origin;
}
export async function POST(request: Request): Promise<Response> {
// A browser session cookie must never be usable by a foreign origin to mint tickets.
const origin = request.headers.get("origin");
if (!origin || origin !== trustedOrigin(request))
return new Response(null, { status: 403, headers: noStore });
const session = await auth();
if (!session?.user?.id)
return new Response(null, { status: 401, headers: noStore });
const secret = process.env.CHAT_TICKET_SECRET?.trim();
if (!secret || secret.length < 32)
return new Response(null, { status: 503, headers: noStore });
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(null, { status: 400, headers: noStore });
}
const parsed = input.safeParse(body);
if (!parsed.success)
return new Response(null, { status: 400, headers: noStore });
try {
const workspace = await resolveWorkspace({
handle: parsed.data.workspace,
userId: session.user.id,
});
const payload = Buffer.from(
JSON.stringify({
iss: "echodo-web",
aud: "echodo-chat-v1",
scope: "chat:subscribe",
sub: session.user.id,
workspaceId: workspace.id,
exp: Math.floor(Date.now() / 1000) + 60,
}),
).toString("base64url");
const signature = createHmac("sha256", secret)
.update(payload)
.digest("base64url");
return Response.json(
{ ticket: `${payload}.${signature}` },
{ headers: noStore },
);
} catch {
// Do not distinguish nonexistent workspaces from denied or failed membership checks.
return new Response(null, { status: 404, headers: noStore });
}
}