78 lines
2.7 KiB
TypeScript
78 lines
2.7 KiB
TypeScript
|
|
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 });
|
||
|
|
}
|
||
|
|
}
|