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 b55dc76..3e16c4a 100644 --- a/apps/web/src/app/api/auth/discord/login/route.ts +++ b/apps/web/src/app/api/auth/discord/login/route.ts @@ -30,7 +30,11 @@ export async function GET(request: NextRequest) { if (host && !isPrimaryHostname(host)) { const oauth = await resolveHostOAuth(host); if (!oauth) { - // No per-guild OAuth app — log in on the primary host instead. + // No usable per-guild OAuth app for this custom domain — fall back to the + // primary host. Logged so a mis-saved secret (present but undecryptable) + // or an unverified domain is diagnosable instead of silently bouncing to + // the global MSK Forms app. + console.warn(`[oauth-login] no per-guild OAuth for host "${host}" — falling back to primary host`); return NextResponse.redirect( absoluteUrl(`/api/auth/discord/login?returnTo=${encodeURIComponent(returnTo)}`), ); diff --git a/apps/web/src/components/site-header.tsx b/apps/web/src/components/site-header.tsx index c5b20f8..66ff3ea 100644 --- a/apps/web/src/components/site-header.tsx +++ b/apps/web/src/components/site-header.tsx @@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle"; import { Wordmark } from "@/components/landing/wordmark"; import { Button } from "@/components/ui/button"; import { isPrimaryHostname, requestHostname } from "@/lib/custom-domain"; +import { resolveHostOAuth } from "@/lib/guild-oauth"; import { appBaseUrl } from "@/lib/url"; import { getDict, getLocale } from "@/i18n"; @@ -18,14 +19,17 @@ export async function SiteHeader({ user }: { user: HeaderUser | null }) { const t = await getDict(); const locale = await getLocale(); - // Auth + dashboard live only on the primary domain (the OAuth state cookie and - // callback must be same-origin). On a guild's custom domain, point those links - // at the primary domain so login doesn't fail with a state mismatch. + // The dashboard lives only on the primary domain. Login is host-aware: on a + // custom domain whose guild has its OWN Discord OAuth app, log in right here + // (relative) so that app is used and the session lands on this domain. Without + // an own app, auth must run on the primary host (the OAuth state cookie and + // callback must be same-origin), else login fails with a state mismatch. const host = await requestHostname(); const onCustomDomain = Boolean(host) && !isPrimaryHostname(host!); - const base = onCustomDomain ? appBaseUrl() : ""; - const loginHref = `${base}/api/auth/discord/login`; - const dashboardHref = `${base}/dashboard`; + const ownOAuth = onCustomDomain ? await resolveHostOAuth(host!) : null; + const loginBase = onCustomDomain && !ownOAuth ? appBaseUrl() : ""; + const loginHref = `${loginBase}/api/auth/discord/login`; + const dashboardHref = `${onCustomDomain ? appBaseUrl() : ""}/dashboard`; return (
diff --git a/apps/web/src/lib/guild-oauth.ts b/apps/web/src/lib/guild-oauth.ts index a2f3963..2dd19fa 100644 --- a/apps/web/src/lib/guild-oauth.ts +++ b/apps/web/src/lib/guild-oauth.ts @@ -19,7 +19,12 @@ export async function getGuildOAuth(guildId: string): Promise }); if (!guild?.oauthClientId || !guild.oauthClientSecret) return null; const clientSecret = decryptSecret(guild.oauthClientSecret); - if (!clientSecret) return null; + if (!clientSecret) { + // Ciphertext present but undecryptable (e.g. SESSION_SECRET changed) — the + // dashboard shows "Active" on presence alone, so surface this here. + console.warn(`[guild-oauth] stored OAuth secret for guild ${guildId} could not be decrypted`); + return null; + } return { clientId: guild.oauthClientId, clientSecret }; }