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
4 changes: 3 additions & 1 deletion apps/web/src/app/api/auth/discord/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ export async function GET(request: NextRequest) {
const storedState = cookieStore.get("oauth_state")?.value;
const returnTo = cookieStore.get("oauth_return_to")?.value ?? "/dashboard";
const origin = cookieStore.get("oauth_origin")?.value ?? "";
const bind = cookieStore.get("oauth_bind")?.value ?? null;

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

if (!code || !state || !storedState || state !== storedState) {
return NextResponse.redirect(absoluteUrl("/?auth=error"));
Expand Down Expand Up @@ -95,7 +97,7 @@ export async function GET(request: NextRequest) {
// 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);
const handoff = await createHandoffToken(user.id, bind);
if (handoff) {
const url = new URL(`https://${origin}/api/auth/handoff`);
url.searchParams.set("token", handoff);
Expand Down
15 changes: 15 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 { isValidBindNonce } from "@/lib/auth-handoff";
import { getGuildByDomain, isPrimaryHostname } from "@/lib/custom-domain";
import { buildAuthorizeUrl } from "@/lib/discord";
import { safeRelativePath } from "@/lib/url";
Expand Down Expand Up @@ -31,6 +32,11 @@ export async function GET(request: NextRequest) {
? rawOrigin
: "";

// Binding nonce from /api/auth/start (set as a host-only cookie on the custom
// domain). Carried through to the callback so the minted token is tied to it.
const rawBind = request.nextUrl.searchParams.get("bind");
const bind = isValidBindNonce(rawBind) ? rawBind : "";

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

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

import { redeemHandoffToken } from "@/lib/auth-handoff";
Expand Down Expand Up @@ -47,19 +48,30 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(dest);
}

const userId = await redeemHandoffToken(token);
if (!userId) {
const claims = await redeemHandoffToken(token);
if (!claims) {
// Expired, already used, or Redis unavailable — indistinguishable here by design.
console.warn("[auth-handoff] no valid token (expired, reused, or store unavailable)");
return NextResponse.redirect(dest);
}

// Browser-binding: the token's bind nonce must match the host-only cookie set
// by /api/auth/start before the OAuth round-trip. A token leaked on its own
// (e.g. from an access log) is then not redeemable from another browser.
const cookieStore = await cookies();
const bindCookie = cookieStore.get("handoff_bind")?.value ?? null;
if (claims.bind && claims.bind !== bindCookie) {
console.warn("[auth-handoff] bind mismatch — token not redeemed");
return NextResponse.redirect(dest);
}
cookieStore.delete("handoff_bind");

const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: claims.userId },
select: { id: true, discordId: true, username: true, avatar: true },
});
if (!user) {
console.warn(`[auth-handoff] token user ${userId} no longer exists`);
console.warn(`[auth-handoff] token user ${claims.userId} no longer exists`);
return NextResponse.redirect(dest);
}

Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/app/api/auth/start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from "next/server";

import { generateBindNonce } from "@/lib/auth-handoff";
import { getGuildByDomain, isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import { absoluteUrl, appBaseUrl, safeRelativePath } from "@/lib/url";

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

/** Lifetime of the OAuth round-trip cookies (10 minutes). */
const MAX_AGE = 600;

/**
* Start the login flow FROM a custom domain. Sets a host-only `handoff_bind`
* nonce cookie on the custom domain, then redirects to the primary-host Discord
* login carrying that nonce. After OAuth, the cross-domain handoff requires the
* cookie to match the token's bound nonce — so a token leaked on its own (e.g.
* from an access log) can't be redeemed by another browser. Auth itself still
* happens on the primary host (same-origin OAuth state/callback).
*/
export async function GET(request: NextRequest) {
const returnTo = safeRelativePath(request.nextUrl.searchParams.get("returnTo"), "/");

// Only meaningful on a verified custom domain; otherwise just hand off to the
// normal primary-host login.
const host = await requestHostname();
const isCustom = host && !isPrimaryHostname(host) && (await getGuildByDomain(host));
if (!host || !isCustom) {
return NextResponse.redirect(
absoluteUrl(`/api/auth/discord/login?returnTo=${encodeURIComponent(returnTo)}`),
);
}

const bind = generateBindNonce();
const target = new URL(`${appBaseUrl()}/api/auth/discord/login`);
target.searchParams.set("returnTo", returnTo);
target.searchParams.set("origin", host);
target.searchParams.set("bind", bind);

const res = NextResponse.redirect(target.toString());
// Host-only (no Domain attribute) cookie on THIS custom domain. SameSite=Lax so
// it is still sent on the final top-level GET navigation back from the callback.
res.cookies.set("handoff_bind", bind, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: MAX_AGE,
});
return res;
}
8 changes: 4 additions & 4 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { LocalDateTime } from "@/components/public/local-datetime";
import { PoweredBy } from "@/components/public/powered-by";
import { experimentCookieName } from "@/lib/experiment";
import { getCurrentUser } from "@/lib/auth";
import { appBaseUrl } from "@/lib/url";
import { brandStyle, logoUrl, parseBranding } from "@/lib/branding";
import { getGuildByDomain, isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import { isGuildPro } from "@/lib/plan";
Expand Down Expand Up @@ -43,10 +42,11 @@ export default async function PublicFormPage({
if (!domainGuild || domainGuild.id !== form.guildId) notFound();
}
// 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.
// From a custom domain, /api/auth/start (same origin) sets a host-only binding
// cookie, then bounces to the primary login; the callback hands the session
// back here via a browser-bound one-time token.
const loginHref = onCustomDomain
? `${appBaseUrl()}/api/auth/discord/login?returnTo=${encodeURIComponent(`/f/${slug}`)}&origin=${encodeURIComponent(host!)}`
? `/api/auth/start?returnTo=${encodeURIComponent(`/f/${slug}`)}`
: `/api/auth/discord/login?returnTo=/f/${slug}`;

const branding = parseBranding(form.guild.branding);
Expand Down
45 changes: 37 additions & 8 deletions apps/web/src/lib/auth-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,46 @@ import { getRedis } from "@/lib/redis";
* single-use token, redirect to the custom domain's `/api/auth/handoff`, and that
* route redeems the token and establishes a session there.
*
* Browser-bound: the token is stored with a `bind` nonce that the originating
* browser also holds in a host-only `handoff_bind` cookie on the custom domain
* (set by `/api/auth/start` before the OAuth round-trip). Redemption requires the
* cookie to match, so a token leaked on its own (e.g. from an access log) is not
* a usable credential.
*
* 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> {
/** Generate a binding nonce for the `handoff_bind` cookie (32 hex chars). */
export function generateBindNonce(): string {
return randomBytes(16).toString("hex");
}

/** True for a well-formed bind nonce (defends the cookie/param against junk). */
export function isValidBindNonce(value: string | null | undefined): value is string {
return typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
}

/** What a redeemed handoff token resolves to. */
export interface HandoffClaims {
userId: string;
/** Binding nonce that must match the `handoff_bind` cookie, or null (unbound). */
bind: string | null;
}

/**
* Mint a one-time handoff token bound to `userId` (and a `bind` nonce), or null
* when Redis is unavailable.
*/
export async function createHandoffToken(userId: string, bind: string | null): 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);
await redis.set(`${PREFIX}${token}`, JSON.stringify({ userId, bind }), "EX", TTL_SECONDS);
return token;
} catch (err) {
console.error("[auth-handoff] failed to store token:", (err as Error).message);
Expand All @@ -33,15 +59,18 @@ export async function createHandoffToken(userId: string): Promise<string | 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.
* Redeem a handoff token, returning its claims (and atomically deleting it so it
* can't be reused), or null when missing/expired/Redis-down/malformed.
*/
export async function redeemHandoffToken(token: string): Promise<string | null> {
export async function redeemHandoffToken(token: string): Promise<HandoffClaims | null> {
const redis = getRedis();
if (!redis) return null;
try {
const userId = await redis.getdel(`${PREFIX}${token}`);
return userId || null;
const raw = await redis.getdel(`${PREFIX}${token}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<HandoffClaims>;
if (typeof parsed.userId !== "string") return null;
return { userId: parsed.userId, bind: typeof parsed.bind === "string" ? parsed.bind : null };
} catch (err) {
console.error("[auth-handoff] failed to redeem token:", (err as Error).message);
return null;
Expand Down