From 7642076ef168c1797488b8413e3228a8a7fdc43f Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 25 Jun 2026 08:32:16 +0200 Subject: [PATCH] feat(auth): return to the custom domain after login (cross-domain handoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in from a custom domain sent the user to the primary host for OAuth (required) and left them there — a session cookie set on the primary host can't be read on a different registrable domain. Now the session is handed back to the originating custom domain so the applicant returns there logged in. Flow: - The public form's login link on a custom domain passes `origin=` (validated server-side as a verified custom domain). - The OAuth callback (primary host), after establishing the session, mints a one-time handoff token (random 256-bit, 60s TTL, single-use, stored in Redis bound to the user id — never any session data) and redirects to `https:///api/auth/handoff?token=…&returnTo=…`. - New `/api/auth/handoff` route runs on the custom domain: redeems the token (atomic GETDEL), sets the iron-session cookie for that host, and redirects to the relative returnTo. Security: origin and handoff host are both validated against the DB (verified custom domains only); returnTo is relative-only; redemption is rate-limited; fail-CLOSED without Redis (no handoff, user stays on primary as before). No schema or env change. --- .../app/api/auth/discord/callback/route.ts | 17 ++++++ .../src/app/api/auth/discord/login/route.ts | 20 ++++++ apps/web/src/app/api/auth/handoff/route.ts | 61 +++++++++++++++++++ apps/web/src/app/f/[slug]/page.tsx | 7 ++- apps/web/src/lib/auth-handoff.ts | 47 ++++++++++++++ 5 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/api/auth/handoff/route.ts create mode 100644 apps/web/src/lib/auth-handoff.ts diff --git a/apps/web/src/app/api/auth/discord/callback/route.ts b/apps/web/src/app/api/auth/discord/callback/route.ts index 1382532..ccd63ae 100644 --- a/apps/web/src/app/api/auth/discord/callback/route.ts +++ b/apps/web/src/app/api/auth/discord/callback/route.ts @@ -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, @@ -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")); @@ -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); diff --git a/apps/web/src/app/api/auth/discord/login/route.ts b/apps/web/src/app/api/auth/discord/login/route.ts index 37ed339..e8d342e 100644 --- a/apps/web/src/app/api/auth/discord/login/route.ts +++ b/apps/web/src/app/api/auth/discord/login/route.ts @@ -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). @@ -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, { @@ -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)); } diff --git a/apps/web/src/app/api/auth/handoff/route.ts b/apps/web/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..4e9710e --- /dev/null +++ b/apps/web/src/app/api/auth/handoff/route.ts @@ -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); +} diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 39d6bab..be04f1e 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -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); diff --git a/apps/web/src/lib/auth-handoff.ts b/apps/web/src/lib/auth-handoff.ts new file mode 100644 index 0000000..73b382b --- /dev/null +++ b/apps/web/src/lib/auth-handoff.ts @@ -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 { + 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 { + const redis = getRedis(); + if (!redis) return null; + try { + const userId = await redis.getdel(`${PREFIX}${token}`); + return userId || null; + } catch { + return null; + } +}