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
49 changes: 22 additions & 27 deletions apps/web/src/app/api/auth/discord/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,26 @@ 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 { isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import {
discordAvatarUrl,
exchangeCode,
fetchDiscordGuildIds,
fetchDiscordUser,
mapLocale,
} from "@/lib/discord";
import { resolveHostOAuth } from "@/lib/guild-oauth";
import { getSession } from "@/lib/session";
import { absoluteUrl } from "@/lib/url";
import { appBaseUrl, safeRelativePath } from "@/lib/url";

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

/**
* Discord OAuth2 callback: validate the CSRF `state`, exchange the code,
* upsert the user, and establish the session cookie.
* Discord OAuth2 callback: validate the CSRF `state`, exchange the code, upsert
* the user, and establish the session cookie on THIS host. On a verified custom
* domain with its own OAuth app, that means the applicant is logged in directly
* on the custom domain (no cross-domain handoff).
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
Expand All @@ -28,22 +30,28 @@ 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 ?? "";
const bind = cookieStore.get("oauth_bind")?.value ?? null;
const returnTo = safeRelativePath(cookieStore.get("oauth_return_to")?.value, "/dashboard");

// 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");

// Land the user on the same host the flow ran on (custom domain or primary).
const host = await requestHostname();
const onCustomDomain = Boolean(host) && !isPrimaryHostname(host!);
const base = onCustomDomain ? `https://${host}` : appBaseUrl();
const errorUrl = new URL("/?auth=error", base);

if (!code || !state || !storedState || state !== storedState) {
return NextResponse.redirect(absoluteUrl("/?auth=error"));
return NextResponse.redirect(errorUrl);
}

try {
const token = await exchangeCode(code);
// Per-guild OAuth app when on the guild's own custom domain; else the global
// app. The same redirect_uri/client_id as the authorize step (Discord checks).
const app = (await resolveHostOAuth(host)) ?? undefined;

const token = await exchangeCode(code, app);
const discordUser = await fetchDiscordUser(token.access_token);

const avatar = discordAvatarUrl(discordUser);
Expand Down Expand Up @@ -93,22 +101,9 @@ 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, bind);
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));
return NextResponse.redirect(new URL(returnTo, base));
} catch (error) {
console.error("Discord OAuth callback failed:", error);
return NextResponse.redirect(absoluteUrl("/?auth=error"));
return NextResponse.redirect(errorUrl);
}
}
65 changes: 24 additions & 41 deletions apps/web/src/app/api/auth/discord/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,42 @@
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 { isPrimaryHostname, requestHostname } from "@/lib/custom-domain";
import { buildAuthorizeUrl } from "@/lib/discord";
import { safeRelativePath } from "@/lib/url";
import { resolveHostOAuth } from "@/lib/guild-oauth";
import { absoluteUrl, safeRelativePath } from "@/lib/url";

// Prisma/iron-session need the Node.js runtime (not Edge).
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* Start the Discord OAuth2 flow: generate a CSRF `state`, stash it (plus an
* optional post-login redirect) in a short-lived HttpOnly cookie, then 302 to
* Discord. The callback verifies the state.
* Start the Discord OAuth2 flow: generate a CSRF `state`, stash it (plus the
* post-login redirect) in short-lived HttpOnly cookies, then 302 to Discord.
*
* Host-aware: on a verified custom domain whose guild has its own Discord OAuth
* app, the whole flow runs on that host (its own client_id + callback) so the
* session cookie is established directly on the custom domain — no cross-domain
* handoff. On a custom domain WITHOUT its own app, fall back to the primary host.
*/
export async function GET(request: NextRequest) {
const state = crypto.randomUUID();

// Only allow same-origin relative redirects to avoid open-redirect abuse
// (rejects `//`, backslashes and control chars — see safeRelativePath).
// Same-origin relative redirect only (rejects "//", backslashes, control chars).
const returnTo = safeRelativePath(request.nextUrl.searchParams.get("returnTo"), "/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
: "";

// 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 host = await requestHostname();
let app: { clientId: string; redirectUri: string } | undefined;
if (host && !isPrimaryHostname(host)) {
const oauth = await resolveHostOAuth(host);
if (!oauth) {
// No per-guild OAuth app — log in on the primary host instead.
return NextResponse.redirect(
absoluteUrl(`/api/auth/discord/login?returnTo=${encodeURIComponent(returnTo)}`),
);
}
app = { clientId: oauth.clientId, redirectUri: oauth.redirectUri };
}

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

return NextResponse.redirect(buildAuthorizeUrl(state));
return NextResponse.redirect(buildAuthorizeUrl(state, app));
}
87 changes: 0 additions & 87 deletions apps/web/src/app/api/auth/handoff/route.ts

This file was deleted.

51 changes: 0 additions & 51 deletions apps/web/src/app/api/auth/start/route.ts

This file was deleted.

Loading