From 070d46ffadd72e0b07cfe5e5b01b3e3ecf90e905 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 25 Jun 2026 09:37:38 +0200 Subject: [PATCH] feat(auth): per-guild Discord OAuth app (replaces cross-domain handoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guild can configure its own Discord application so login runs end-to-end on its custom domain — the session cookie is set directly there, with no cross-domain handoff. This replaces the fragile handoff (#143/#145), which is removed; the open-redirect fix from #144 (safeRelativePath) is kept. - Schema: `Guild.oauthClientId` + `oauthClientSecret` (secret encrypted at rest, AES-256-GCM, key derived from SESSION_SECRET — `lib/crypto.ts`). Migration. - `lib/guild-oauth.ts`: `getGuildOAuth` (decrypt) + `resolveHostOAuth(host)` → per-guild app + that host's callback for a verified custom domain, else null. - `lib/discord.ts`: `buildAuthorizeUrl`/`exchangeCode` take optional per-guild credentials (default to the global env app). - login (host-aware): on a verified custom domain with its own app, run OAuth here; otherwise fall back to the primary host. callback: exchange with the matching app and land the user on the same host. - Removed `/api/auth/handoff`, `/api/auth/start`, `lib/auth-handoff.ts` and the origin/bind cookie machinery. Public form login link is a plain relative link. - Dashboard: "Own Discord login" section on the Domain page (Pro+, manager-only) with the redirect URL to register + Client ID/Secret; API `PATCH/DELETE /api/guilds/[guildId]/oauth` (secret kept on blank, never returned). DE/EN/HU. Customers without their own app keep the previous behaviour (login on the primary host). No new env (migration via deploy `prisma migrate deploy`). --- .../app/api/auth/discord/callback/route.ts | 49 +++--- .../src/app/api/auth/discord/login/route.ts | 65 +++----- apps/web/src/app/api/auth/handoff/route.ts | 87 ---------- apps/web/src/app/api/auth/start/route.ts | 51 ------ .../app/api/guilds/[guildId]/oauth/route.ts | 84 ++++++++++ .../app/dashboard/[guildId]/domain/page.tsx | 15 +- apps/web/src/app/f/[slug]/page.tsx | 12 +- apps/web/src/components/domain/oauth-form.tsx | 153 ++++++++++++++++++ apps/web/src/i18n/dictionaries.ts | 48 ++++++ apps/web/src/lib/auth-handoff.ts | 78 --------- apps/web/src/lib/crypto.ts | 42 +++++ apps/web/src/lib/discord.ts | 24 ++- apps/web/src/lib/guild-oauth.ts | 48 ++++++ .../20260625120000_guild_oauth/migration.sql | 4 + packages/db/prisma/schema.prisma | 4 + 15 files changed, 465 insertions(+), 299 deletions(-) delete mode 100644 apps/web/src/app/api/auth/handoff/route.ts delete mode 100644 apps/web/src/app/api/auth/start/route.ts create mode 100644 apps/web/src/app/api/guilds/[guildId]/oauth/route.ts create mode 100644 apps/web/src/components/domain/oauth-form.tsx delete mode 100644 apps/web/src/lib/auth-handoff.ts create mode 100644 apps/web/src/lib/crypto.ts create mode 100644 apps/web/src/lib/guild-oauth.ts create mode 100644 packages/db/prisma/migrations/20260625120000_guild_oauth/migration.sql 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 4c63725..abfdc31 100644 --- a/apps/web/src/app/api/auth/discord/callback/route.ts +++ b/apps/web/src/app/api/auth/discord/callback/route.ts @@ -2,8 +2,7 @@ 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, @@ -11,15 +10,18 @@ import { 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; @@ -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); @@ -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); } } 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 0f2f168..b55dc76 100644 --- a/apps/web/src/app/api/auth/discord/login/route.ts +++ b/apps/web/src/app/api/auth/discord/login/route.ts @@ -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"; @@ -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)); } diff --git a/apps/web/src/app/api/auth/handoff/route.ts b/apps/web/src/app/api/auth/handoff/route.ts deleted file mode 100644 index 496b672..0000000 --- a/apps/web/src/app/api/auth/handoff/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { prisma } from "@msk-forms/db"; -import { cookies } from "next/headers"; -import { NextResponse, type NextRequest } from "next/server"; - -import { redeemHandoffToken } from "@/lib/auth-handoff"; -import { getGuildByDomain, isPrimaryHostname, requestHostname } from "@/lib/custom-domain"; -import { getSession } from "@/lib/session"; -import { clientIp, rateLimit } from "@/lib/rate-limit"; -import { absoluteUrl, safeRelativePath } from "@/lib/url"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** Token-redemption throttle: 20 attempts per 60s per IP. */ -const REDEEM_LIMIT = 20; -const REDEEM_WINDOW_SECONDS = 60; - -/** - * 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) { - // Relative-only target (rejects "//", backslashes and control chars that could - // turn into an off-origin URL once resolved). - const returnTo = safeRelativePath(request.nextUrl.searchParams.get("returnTo"), "/"); - const token = request.nextUrl.searchParams.get("token"); - - // Resolve + validate the host via the shared helper (strips port + trailing - // dot, consistent with the rest of the custom-domain logic). The handoff is - // only meaningful on a verified custom domain. - const host = await requestHostname(); - const domainGuild = host && !isPrimaryHostname(host) ? await getGuildByDomain(host) : null; - if (!host || !domainGuild) { - // Not a verified custom domain — nothing to hand off; go to the primary copy. - return NextResponse.redirect(absoluteUrl(returnTo)); - } - - // Build the destination from the validated custom-domain host — never from - // request.url, which resolves to the loopback backend behind Apache (see - // lib/url.ts). https is intrinsic to the public custom domain. - const dest = new URL(returnTo, `https://${host}`); - - // Throttle redemption attempts. - const rl = await rateLimit(`handoff:${clientIp(request.headers)}`, REDEEM_LIMIT, REDEEM_WINDOW_SECONDS); - if (!rl.allowed || !token) { - return NextResponse.redirect(dest); - } - - 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: claims.userId }, - select: { id: true, discordId: true, username: true, avatar: true }, - }); - if (!user) { - console.warn(`[auth-handoff] token user ${claims.userId} no longer exists`); - 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/api/auth/start/route.ts b/apps/web/src/app/api/auth/start/route.ts deleted file mode 100644 index 353e8c9..0000000 --- a/apps/web/src/app/api/auth/start/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -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; -} diff --git a/apps/web/src/app/api/guilds/[guildId]/oauth/route.ts b/apps/web/src/app/api/guilds/[guildId]/oauth/route.ts new file mode 100644 index 0000000..1bad11c --- /dev/null +++ b/apps/web/src/app/api/guilds/[guildId]/oauth/route.ts @@ -0,0 +1,84 @@ +import { prisma } from "@msk-forms/db"; +import { NextResponse, type NextRequest } from "next/server"; + +import { getCurrentUser } from "@/lib/auth"; +import { encryptSecret } from "@/lib/crypto"; +import { canManageForms } from "@/lib/guild"; +import { isGuildPro } from "@/lib/plan"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** Discord application (client) IDs are snowflakes. */ +const CLIENT_ID_RE = /^\d{17,20}$/; + +/** + * Set the guild's own Discord OAuth app (Pro+). Lets the guild's custom domain + * run login end-to-end instead of bouncing to the primary host. Owner/admin only. + * The client secret is stored encrypted; an empty secret keeps the existing one. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + if (!(await canManageForms(guildId, user.id))) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + if (!(await isGuildPro(guildId))) { + return NextResponse.json({ error: "Pro plan required.", code: "pro_required" }, { status: 402 }); + } + + const body = (await request.json().catch(() => null)) as + | { clientId?: unknown; clientSecret?: unknown } + | null; + const clientId = typeof body?.clientId === "string" ? body.clientId.trim() : ""; + const clientSecret = typeof body?.clientSecret === "string" ? body.clientSecret.trim() : ""; + + if (!CLIENT_ID_RE.test(clientId)) { + return NextResponse.json({ error: "Enter a valid Discord application (client) ID." }, { status: 422 }); + } + + const existing = await prisma.guild.findUnique({ + where: { id: guildId }, + select: { oauthClientSecret: true }, + }); + + // A blank secret keeps the stored one (so saving the ID alone doesn't wipe it); + // require a secret the first time it's configured. + let secretToStore = existing?.oauthClientSecret ?? null; + if (clientSecret) { + secretToStore = encryptSecret(clientSecret); + } else if (!secretToStore) { + return NextResponse.json({ error: "Enter the client secret." }, { status: 422 }); + } + + await prisma.guild.update({ + where: { id: guildId }, + data: { oauthClientId: clientId, oauthClientSecret: secretToStore }, + }); + return NextResponse.json({ ok: true, hasSecret: true }); +} + +/** Remove the guild's custom Discord OAuth app. Owner/admin only. */ +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + if (!(await canManageForms(guildId, user.id))) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + await prisma.guild.update({ + where: { id: guildId }, + data: { oauthClientId: null, oauthClientSecret: null }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/dashboard/[guildId]/domain/page.tsx b/apps/web/src/app/dashboard/[guildId]/domain/page.tsx index 2e61198..ad9f04a 100644 --- a/apps/web/src/app/dashboard/[guildId]/domain/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/domain/page.tsx @@ -3,6 +3,7 @@ import { Card } from "@msk-forms/ui"; import { UpgradeActions } from "@/components/billing/upgrade-button"; import { DomainForm } from "@/components/domain/domain-form"; +import { OAuthForm } from "@/components/domain/oauth-form"; import { ProNotice } from "@/components/pro-notice"; import { requireUser } from "@/lib/auth"; import { primaryHostname } from "@/lib/custom-domain"; @@ -55,7 +56,13 @@ export default async function DomainPage({ const guild = await prisma.guild.findUnique({ where: { id: guildId }, - select: { customDomain: true, customDomainToken: true, customDomainVerifiedAt: true }, + select: { + customDomain: true, + customDomainToken: true, + customDomainVerifiedAt: true, + oauthClientId: true, + oauthClientSecret: true, + }, }); return ( @@ -74,6 +81,12 @@ export default async function DomainPage({ }} t={t} /> + ); } diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 3c78240..dda5131 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -41,13 +41,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, /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 - ? `/api/auth/start?returnTo=${encodeURIComponent(`/f/${slug}`)}` - : `/api/auth/discord/login?returnTo=/f/${slug}`; + // Same-origin login link for both hosts. The login route is host-aware: on a + // custom domain whose guild has its own Discord OAuth app it runs the whole + // flow here (session set on this domain); otherwise it falls back to the + // primary host. + const loginHref = `/api/auth/discord/login?returnTo=${encodeURIComponent(`/f/${slug}`)}`; const branding = parseBranding(form.guild.branding); const brand = brandStyle(branding); diff --git a/apps/web/src/components/domain/oauth-form.tsx b/apps/web/src/components/domain/oauth-form.tsx new file mode 100644 index 0000000..0ed6c10 --- /dev/null +++ b/apps/web/src/components/domain/oauth-form.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { Button, Card, Field, Input } from "@msk-forms/ui"; +import { useState } from "react"; + +import type { Dictionary } from "@/i18n"; + +type OAuthDict = Dictionary["domain"]["oauth"]; + +export function OAuthForm({ + guildId, + customDomain, + initial, + t, +}: { + guildId: string; + /** The verified custom domain, or "" when none is verified yet. */ + customDomain: string; + initial: { clientId: string; hasSecret: boolean }; + t: OAuthDict; +}) { + const [clientId, setClientId] = useState(initial.clientId); + const [clientSecret, setClientSecret] = useState(""); + const [hasSecret, setHasSecret] = useState(initial.hasSecret); + const [saving, setSaving] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const redirectUri = customDomain ? `https://${customDomain}/api/auth/discord/callback` : ""; + const configured = Boolean(initial.clientId) && initial.hasSecret; + + async function save() { + setError(null); + setSaving(true); + try { + const res = await fetch(`/api/guilds/${guildId}/oauth`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim() }), + }); + const data = (await res.json().catch(() => null)) as { error?: string } | null; + if (!res.ok) throw new Error(data?.error ?? t.errSave); + setHasSecret(true); + setClientSecret(""); + } catch (err) { + setError(err instanceof Error ? err.message : t.errSave); + } finally { + setSaving(false); + } + } + + async function remove() { + setError(null); + setRemoving(true); + try { + const res = await fetch(`/api/guilds/${guildId}/oauth`, { method: "DELETE" }); + if (!res.ok) throw new Error(t.errAction); + setClientId(""); + setClientSecret(""); + setHasSecret(false); + } catch (err) { + setError(err instanceof Error ? err.message : t.errAction); + } finally { + setRemoving(false); + } + } + + async function copyRedirect() { + try { + await navigator.clipboard.writeText(redirectUri); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable */ + } + } + + return ( + +
+

{t.title}

+ {configured && ( + + {t.active} + + )} +
+

{t.intro}

+ + {!customDomain ? ( +

+ {t.needDomain} +

+ ) : ( + <> +
    +
  1. {t.step1}
  2. +
  3. {t.step2}
  4. +
  5. {t.step3}
  6. +
+ + +
+ + {redirectUri} + + +
+
+ + + setClientId(e.target.value)} + placeholder="123456789012345678" + inputMode="numeric" + /> + + + + setClientSecret(e.target.value)} + placeholder={hasSecret ? t.secretPlaceholderSet : t.secretPlaceholderNew} + autoComplete="off" + /> + + + {error &&

{error}

} + +
+ + {configured && ( + + )} +
+ + )} +
+ ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index f43247f..458032e 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -312,6 +312,22 @@ const en = { verifyFailed: "Could not verify yet. DNS changes can take a few minutes.", propagation: "After verifying, the certificate is issued automatically within a few minutes.", noPerm: "You don’t have permission to manage the domain.", + oauth: { + title: "Own Discord login", + intro: "Use your own Discord application so login runs entirely on your custom domain (no redirect to the main site).", + needDomain: "Verify a custom domain first — the login then runs on it.", + step1: "Create an application at discord.com/developers (OAuth2).", + step2: "Add the redirect URL below under OAuth2 → Redirects, with scopes identify, email, guilds.", + step3: "Copy the Client ID and Client Secret into the fields below.", + redirectLabel: "Redirect URL (add this in Discord)", + clientId: "Client ID", + clientSecretLabel: "Client Secret", + secretPlaceholderSet: "•••••••• (unchanged — leave blank to keep)", + secretPlaceholderNew: "Client secret", + copy: "Copy", copied: "Copied!", + active: "Active", save: "Save", saving: "Saving…", remove: "Remove", removing: "Removing…", + errSave: "Could not save the credentials.", errAction: "Action failed.", + }, }, api: { title: "API", @@ -740,6 +756,22 @@ const de: Dictionary = { verifyFailed: "Verifizierung noch nicht möglich. DNS-Änderungen können einige Minuten dauern.", propagation: "Nach der Verifizierung wird das Zertifikat innerhalb weniger Minuten automatisch ausgestellt.", noPerm: "Du hast keine Berechtigung, die Domain zu verwalten.", + oauth: { + title: "Eigener Discord-Login", + intro: "Nutze deine eigene Discord-Anwendung, damit der Login komplett auf deiner Custom-Domain läuft (keine Weiterleitung zur Hauptseite).", + needDomain: "Verifiziere zuerst eine Custom-Domain — der Login läuft dann darauf.", + step1: "Erstelle eine Anwendung auf discord.com/developers (OAuth2).", + step2: "Trage die untenstehende Redirect-URL unter OAuth2 → Redirects ein, mit den Scopes identify, email, guilds.", + step3: "Kopiere Client-ID und Client-Secret in die Felder unten.", + redirectLabel: "Redirect-URL (in Discord eintragen)", + clientId: "Client-ID", + clientSecretLabel: "Client-Secret", + secretPlaceholderSet: "•••••••• (unverändert — leer lassen, um es zu behalten)", + secretPlaceholderNew: "Client-Secret", + copy: "Kopieren", copied: "Kopiert!", + active: "Aktiv", save: "Speichern", saving: "Wird gespeichert…", remove: "Entfernen", removing: "Wird entfernt…", + errSave: "Credentials konnten nicht gespeichert werden.", errAction: "Aktion fehlgeschlagen.", + }, }, api: { title: "API", @@ -1166,6 +1198,22 @@ const hu: Dictionary = { verifyFailed: "Még nem sikerült ellenőrizni. A DNS-változások néhány percig tarthatnak.", propagation: "Az ellenőrzés után a tanúsítvány néhány percen belül automatikusan kiállításra kerül.", noPerm: "Nincs jogosultságod a domain kezeléséhez.", + oauth: { + title: "Saját Discord-bejelentkezés", + intro: "Használd a saját Discord-alkalmazásodat, hogy a bejelentkezés teljesen az egyéni domaineden fusson (nincs átirányítás a fő oldalra).", + needDomain: "Előbb ellenőrizz egy egyéni domaint — a bejelentkezés azon fog futni.", + step1: "Hozz létre egy alkalmazást a discord.com/developers oldalon (OAuth2).", + step2: "Add meg az alábbi átirányítási URL-t az OAuth2 → Redirects alatt, identify, email, guilds scope-okkal.", + step3: "Másold a Client ID-t és a Client Secretet az alábbi mezőkbe.", + redirectLabel: "Átirányítási URL (add meg a Discordban)", + clientId: "Client ID", + clientSecretLabel: "Client Secret", + secretPlaceholderSet: "•••••••• (változatlan — hagyd üresen a megtartásához)", + secretPlaceholderNew: "Client Secret", + copy: "Másol", copied: "Másolva!", + active: "Aktív", save: "Mentés", saving: "Mentés…", remove: "Eltávolítás", removing: "Eltávolítás…", + errSave: "A hitelesítő adatok mentése sikertelen.", errAction: "A művelet sikertelen.", + }, }, api: { title: "API", diff --git a/apps/web/src/lib/auth-handoff.ts b/apps/web/src/lib/auth-handoff.ts deleted file mode 100644 index c82bc1c..0000000 --- a/apps/web/src/lib/auth-handoff.ts +++ /dev/null @@ -1,78 +0,0 @@ -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. - * - * 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; - -/** 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 { - const redis = getRedis(); - if (!redis) return null; - const token = randomBytes(32).toString("hex"); - try { - 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); - return null; - } -} - -/** - * 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 { - const redis = getRedis(); - if (!redis) return null; - try { - const raw = await redis.getdel(`${PREFIX}${token}`); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - 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; - } -} diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts new file mode 100644 index 0000000..c724b33 --- /dev/null +++ b/apps/web/src/lib/crypto.ts @@ -0,0 +1,42 @@ +import "server-only"; + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +/** + * Symmetric encryption for secrets stored at rest (e.g. per-guild Discord OAuth + * client secrets). AES-256-GCM with a key derived from SESSION_SECRET — no new + * required env. Output format: `iv.tag.ciphertext`, all base64. + */ +function key(): Buffer { + const secret = process.env.SESSION_SECRET; + if (!secret || secret.length < 32) { + throw new Error("SESSION_SECRET must be set (>= 32 chars) to encrypt secrets."); + } + // Domain-separated from the session-cookie use of the same secret. + return createHash("sha256").update(`${secret}:guild-secret-v1`).digest(); +} + +/** Encrypt a plaintext secret. Returns `iv.tag.ciphertext` (base64 parts). */ +export function encryptSecret(plain: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key(), iv); + const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`; +} + +/** Decrypt a value produced by {@link encryptSecret}, or null if it can't be read. */ +export function decryptSecret(enc: string): string | null { + try { + const [ivB, tagB, ctB] = enc.split("."); + if (!ivB || !tagB || !ctB) return null; + const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(ivB, "base64")); + decipher.setAuthTag(Buffer.from(tagB, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(ctB, "base64")), + decipher.final(), + ]).toString("utf8"); + } catch { + return null; + } +} diff --git a/apps/web/src/lib/discord.ts b/apps/web/src/lib/discord.ts index 5f2295e..daf2d1c 100644 --- a/apps/web/src/lib/discord.ts +++ b/apps/web/src/lib/discord.ts @@ -32,11 +32,21 @@ function requireEnv(name: string): string { return value; } +/** + * Optional per-guild OAuth app (for login on a guild's own custom domain). When + * omitted, the global app + primary redirect_uri (from env) are used. + */ +export interface OAuthApp { + clientId: string; + clientSecret: string; + redirectUri: string; +} + /** Build the Discord authorize URL the user is redirected to on login. */ -export function buildAuthorizeUrl(state: string): string { +export function buildAuthorizeUrl(state: string, app?: Pick): string { const url = new URL(`${DISCORD_API}/oauth2/authorize`); - url.searchParams.set("client_id", requireEnv("DISCORD_CLIENT_ID")); - url.searchParams.set("redirect_uri", requireEnv("DISCORD_REDIRECT_URI")); + url.searchParams.set("client_id", app?.clientId ?? requireEnv("DISCORD_CLIENT_ID")); + url.searchParams.set("redirect_uri", app?.redirectUri ?? requireEnv("DISCORD_REDIRECT_URI")); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", OAUTH_SCOPES.join(" ")); url.searchParams.set("state", state); @@ -45,13 +55,13 @@ export function buildAuthorizeUrl(state: string): string { } /** Exchange an authorization code for an access token. */ -export async function exchangeCode(code: string): Promise { +export async function exchangeCode(code: string, app?: OAuthApp): Promise { const body = new URLSearchParams({ - client_id: requireEnv("DISCORD_CLIENT_ID"), - client_secret: requireEnv("DISCORD_CLIENT_SECRET"), + client_id: app?.clientId ?? requireEnv("DISCORD_CLIENT_ID"), + client_secret: app?.clientSecret ?? requireEnv("DISCORD_CLIENT_SECRET"), grant_type: "authorization_code", code, - redirect_uri: requireEnv("DISCORD_REDIRECT_URI"), + redirect_uri: app?.redirectUri ?? requireEnv("DISCORD_REDIRECT_URI"), }); const res = await fetch(`${DISCORD_API}/oauth2/token`, { diff --git a/apps/web/src/lib/guild-oauth.ts b/apps/web/src/lib/guild-oauth.ts new file mode 100644 index 0000000..a2f3963 --- /dev/null +++ b/apps/web/src/lib/guild-oauth.ts @@ -0,0 +1,48 @@ +import "server-only"; + +import { prisma } from "@msk-forms/db"; + +import { decryptSecret } from "@/lib/crypto"; +import { getGuildByDomain, isPrimaryHostname } from "@/lib/custom-domain"; + +/** Decrypted per-guild Discord OAuth credentials. */ +export interface GuildOAuth { + clientId: string; + clientSecret: string; +} + +/** Per-guild Discord OAuth credentials for a guild, or null when not configured. */ +export async function getGuildOAuth(guildId: string): Promise { + const guild = await prisma.guild.findUnique({ + where: { id: guildId }, + select: { oauthClientId: true, oauthClientSecret: true }, + }); + if (!guild?.oauthClientId || !guild.oauthClientSecret) return null; + const clientSecret = decryptSecret(guild.oauthClientSecret); + if (!clientSecret) return null; + return { clientId: guild.oauthClientId, clientSecret }; +} + +/** Full OAuth config to drive the authorize + token exchange on a given host. */ +export interface HostOAuth extends GuildOAuth { + redirectUri: string; +} + +/** + * Resolve the Discord OAuth credentials to use for a request `host`: + * - On a verified custom domain whose guild has its own OAuth app → those creds + * plus that host's callback as the redirect_uri (login completes on the custom + * domain, no cross-domain handoff). + * - Otherwise → null, meaning "use the global app on the primary host". + * + * Login + callback both call this with their own host, so the authorize and the + * token exchange always agree on client_id + redirect_uri (Discord requires it). + */ +export async function resolveHostOAuth(host: string | null): Promise { + if (!host || isPrimaryHostname(host)) return null; + const guild = await getGuildByDomain(host); + if (!guild) return null; + const oauth = await getGuildOAuth(guild.id); + if (!oauth) return null; + return { ...oauth, redirectUri: `https://${host}/api/auth/discord/callback` }; +} diff --git a/packages/db/prisma/migrations/20260625120000_guild_oauth/migration.sql b/packages/db/prisma/migrations/20260625120000_guild_oauth/migration.sql new file mode 100644 index 0000000..9734f77 --- /dev/null +++ b/packages/db/prisma/migrations/20260625120000_guild_oauth/migration.sql @@ -0,0 +1,4 @@ +-- Per-guild Discord OAuth credentials (for login on a guild's own custom domain). +-- The client secret column holds an AES-256-GCM ciphertext, never plaintext. +ALTER TABLE "guilds" ADD COLUMN "oauth_client_id" TEXT; +ALTER TABLE "guilds" ADD COLUMN "oauth_client_secret" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 315a044..1f172d5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -101,6 +101,10 @@ model Guild { customDomain String? @unique @map("custom_domain") customDomainToken String? @map("custom_domain_token") customDomainVerifiedAt DateTime? @map("custom_domain_verified_at") + /// Per-guild Discord OAuth app (for login on the guild's own custom domain). + /// The client secret is stored encrypted (AES-256-GCM, see lib/crypto.ts). + oauthClientId String? @map("oauth_client_id") + oauthClientSecret String? @map("oauth_client_secret") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at")