Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/web/src/app/api/auth/discord/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { prisma } from "@msk-forms/db";
import { cookies } from "next/headers";
import { NextResponse, type NextRequest } from "next/server";

import { createHandoffToken } from "@/lib/auth-handoff";
import { getGuildByDomain, isPrimaryHostname } from "@/lib/custom-domain";
import {
discordAvatarUrl,
exchangeCode,
Expand All @@ -27,10 +29,12 @@ export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const storedState = cookieStore.get("oauth_state")?.value;
const returnTo = cookieStore.get("oauth_return_to")?.value ?? "/dashboard";
const origin = cookieStore.get("oauth_origin")?.value ?? "";

// Clear the one-shot CSRF cookies regardless of outcome.
cookieStore.delete("oauth_state");
cookieStore.delete("oauth_return_to");
cookieStore.delete("oauth_origin");

if (!code || !state || !storedState || state !== storedState) {
return NextResponse.redirect(absoluteUrl("/?auth=error"));
Expand Down Expand Up @@ -87,6 +91,19 @@ export async function GET(request: NextRequest) {
session.isLoggedIn = true;
await session.save();

// If login started on a verified custom domain, hand the session back there
// via a one-time token (a primary-host cookie can't be read cross-domain).
// Re-validate the origin so a tampered cookie can't redirect us elsewhere.
if (origin && !isPrimaryHostname(origin) && (await getGuildByDomain(origin))) {
const handoff = await createHandoffToken(user.id);
if (handoff) {
const url = new URL(`https://${origin}/api/auth/handoff`);
url.searchParams.set("token", handoff);
url.searchParams.set("returnTo", returnTo);
return NextResponse.redirect(url.toString());
}
}

return NextResponse.redirect(absoluteUrl(returnTo));
} catch (error) {
console.error("Discord OAuth callback failed:", error);
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/app/api/auth/discord/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cookies } from "next/headers";
import { NextResponse, type NextRequest } from "next/server";

import { getGuildByDomain, isPrimaryHostname } from "@/lib/custom-domain";
import { buildAuthorizeUrl } from "@/lib/discord";

// Prisma/iron-session need the Node.js runtime (not Edge).
Expand All @@ -21,6 +22,16 @@ export async function GET(request: NextRequest) {
? rawReturnTo
: "/dashboard";

// Optional: the verified custom domain the login was started from. After OAuth
// (which always completes on the primary host) the callback hands the session
// back to this domain. Validated against the DB so we never hand off to an
// arbitrary host.
const rawOrigin = (request.nextUrl.searchParams.get("origin") ?? "").toLowerCase();
const origin =
rawOrigin && !isPrimaryHostname(rawOrigin) && (await getGuildByDomain(rawOrigin))
? rawOrigin
: "";

const cookieStore = await cookies();
const secure = process.env.NODE_ENV === "production";
cookieStore.set("oauth_state", state, {
Expand All @@ -37,6 +48,15 @@ export async function GET(request: NextRequest) {
path: "/",
maxAge: 600,
});
if (origin) {
cookieStore.set("oauth_origin", origin, {
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
maxAge: 600,
});
}

return NextResponse.redirect(buildAuthorizeUrl(state));
}
61 changes: 61 additions & 0 deletions apps/web/src/app/api/auth/handoff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { prisma } from "@msk-forms/db";
import { NextResponse, type NextRequest } from "next/server";

import { redeemHandoffToken } from "@/lib/auth-handoff";
import { getGuildByDomain, isPrimaryHostname } from "@/lib/custom-domain";
import { getSession } from "@/lib/session";
import { clientIp, rateLimit } from "@/lib/rate-limit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Cross-domain login handoff endpoint. Runs on a customer's custom domain: it
* redeems the one-time token minted by the primary-host OAuth callback and
* establishes a session cookie for THIS host, so the applicant returns to the
* custom domain logged in. Always lands the user on `returnTo` (relative).
*/
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get("token");
const rawReturnTo = request.nextUrl.searchParams.get("returnTo") ?? "/";
// Relative-only to avoid open redirects.
const returnTo = rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//") ? rawReturnTo : "/";
const dest = new URL(returnTo, request.url);
// Next talks http to the Apache proxy; the public custom domain is https.
if (process.env.NODE_ENV === "production") dest.protocol = "https:";

// Only meaningful on a verified custom domain (not the primary host).
const host = (request.headers.get("host") ?? "").toLowerCase().split(":")[0]!;
if (!host || isPrimaryHostname(host) || !(await getGuildByDomain(host))) {
return NextResponse.redirect(dest);
}

// Throttle token redemption attempts.
const rl = await rateLimit(`handoff:${clientIp(request.headers)}`, 20, 60);
if (!rl.allowed || !token) {
return NextResponse.redirect(dest);
}

const userId = await redeemHandoffToken(token);
if (!userId) {
return NextResponse.redirect(dest);
}

const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, discordId: true, username: true, avatar: true },
});
if (!user) {
return NextResponse.redirect(dest);
}

const session = await getSession();
session.userId = user.id;
session.discordId = user.discordId;
session.username = user.username;
session.avatar = user.avatar;
session.isLoggedIn = true;
await session.save();

return NextResponse.redirect(dest);
}
7 changes: 4 additions & 3 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ export default async function PublicFormPage({
const domainGuild = await getGuildByDomain(host!);
if (!domainGuild || domainGuild.id !== form.guildId) notFound();
}
// Auth must happen on the primary domain (same-origin OAuth state/callback);
// from a custom domain, send the login there and return to the primary copy.
// Auth must happen on the primary domain (same-origin OAuth state/callback).
// From a custom domain we pass `origin` so the callback hands the session back
// here (one-time token) — the applicant returns to this domain logged in.
const loginHref = onCustomDomain
? `${appBaseUrl()}/api/auth/discord/login?returnTo=/f/${slug}`
? `${appBaseUrl()}/api/auth/discord/login?returnTo=${encodeURIComponent(`/f/${slug}`)}&origin=${encodeURIComponent(host!)}`
: `/api/auth/discord/login?returnTo=/f/${slug}`;

const branding = parseBranding(form.guild.branding);
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/auth-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import "server-only";

import { randomBytes } from "node:crypto";

import { getRedis } from "@/lib/redis";

/**
* Cross-domain login handoff (custom domains). A session cookie set on the
* primary host can't be read on a customer's custom domain (different registrable
* domain). After OAuth completes on the primary host we mint a short-lived,
* single-use token, redirect to the custom domain's `/api/auth/handoff`, and that
* route redeems the token and establishes a session there.
*
* Fail-CLOSED: without Redis there is no token store, so the handoff simply does
* not happen (the user stays logged in on the primary host, as before). The token
* never carries session data — only a random key bound server-side to a user id.
*/
const PREFIX = "handoff:";
const TTL_SECONDS = 60;

/** Mint a one-time handoff token for `userId`, or null when Redis is unavailable. */
export async function createHandoffToken(userId: string): Promise<string | null> {
const redis = getRedis();
if (!redis) return null;
const token = randomBytes(32).toString("hex");
try {
await redis.set(`${PREFIX}${token}`, userId, "EX", TTL_SECONDS);
return token;
} catch {
return null;
}
}

/**
* Redeem a handoff token, returning the bound user id (and atomically deleting it
* so it can't be reused), or null when missing/expired/Redis-down.
*/
export async function redeemHandoffToken(token: string): Promise<string | null> {
const redis = getRedis();
if (!redis) return null;
try {
const userId = await redis.getdel(`${PREFIX}${token}`);
return userId || null;
} catch {
return null;
}
}