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
6 changes: 5 additions & 1 deletion apps/web/src/app/api/auth/discord/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`),
);
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
<header className="sticky top-0 z-40 border-b border-border bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/guild-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export async function getGuildOAuth(guildId: string): Promise<GuildOAuth | null>
});
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 };
}

Expand Down