- /discord/connected: Discord's OAuth redirect target. Stashes code/state
in sessionStorage, strips them from the URL (replaceState), no-referrer
(metadata + response header), sends a signed-out user to
/login?next=/discord/connected and resumes, access_denied = "Nothing
changed", result card "Linked X: N ready, M need a fix".
- middleware.ts: the callback path joins NO_SESSION_COOKIE_GATE (plan
finding 2). Also needed for signed-in users: drb_session is
SameSite=Strict, so it is not sent on the cross-site redirect back from
discord.com.
- /login honours ?next= for an allowlist of one path (no open redirect).
- DiscordServersCard: Connect is the primary action with a radio-bot
picker (automatic / the bot for a system / the org's own bots); the
/drb link code moves to a collapsed secondary option and opens itself
when /discord/oauth/start answers 404.
- RadioBotsPanel: write-only client-secret field per bot (PUT/DELETE
/tokens/{id}/oauth), "Connect on/off", "N of 100 servers" with the
pool cap (90) warning.
- lib/discordOAuth.ts: the S6 client, kept out of c2api.ts; only ever
navigates to a discord.com /oauth2/authorize URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
59 lines
2.5 KiB
TypeScript
59 lines
2.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
// Public marketing pages — no session required. Keep this in sync with
|
|
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
|
|
// chrome; this one decides whether to redirect at all).
|
|
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq", "/terms", "/privacy", "/waitlist"]);
|
|
|
|
// /signup and /onboarding are deliberately NOT gated by the drb_session
|
|
// cookie here, even though they aren't "public" in the sense of not needing
|
|
// an account — AuthProvider only sets that cookie once a user has an org_id
|
|
// claim (SAAS_PLAN.md B3's no-claim guard), and /onboarding exists
|
|
// specifically for a signed-in user who doesn't have one yet. Gating it on
|
|
// the same cookie would bounce the exact users who need it back to /login
|
|
// before ChromeSwitcher's client-side redirect ever runs. Both pages do
|
|
// their own client-side auth check (redirect to /login if genuinely signed
|
|
// out) instead.
|
|
//
|
|
// /discord/connected is Discord's OAuth redirect target (server-26#225 S7).
|
|
// Redirecting it to /login would drop the query — the single-use code and
|
|
// state — and the cookie is SameSite=Strict, so it is NOT sent on the
|
|
// cross-site redirect back from discord.com even for a signed-in user. The
|
|
// page stashes code/state, sends a signed-out user to /login?next=…, and
|
|
// resumes when they come back.
|
|
const OAUTH_CALLBACK_PATH = "/discord/connected";
|
|
const NO_SESSION_COOKIE_GATE = new Set(["/signup", "/onboarding", OAUTH_CALLBACK_PATH]);
|
|
|
|
// NOTE: this is a UX redirect only, not a security boundary — it just checks
|
|
// a client-set cookie's presence. Real enforcement is server-side, in
|
|
// drb-c2-core/app/internal/auth.py. See CLAUDE.md.
|
|
export function middleware(request: NextRequest) {
|
|
const session = request.cookies.get("drb_session");
|
|
const { pathname } = request.nextUrl;
|
|
|
|
if (pathname === OAUTH_CALLBACK_PATH) {
|
|
// The URL carries an OAuth code until the page strips it: never leak it in a Referer.
|
|
const res = NextResponse.next();
|
|
res.headers.set("Referrer-Policy", "no-referrer");
|
|
return res;
|
|
}
|
|
|
|
if (PUBLIC_PATHS.has(pathname) || NO_SESSION_COOKIE_GATE.has(pathname)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
if (pathname === "/login") {
|
|
if (session) return NextResponse.redirect(new URL("/", request.url));
|
|
return NextResponse.next();
|
|
}
|
|
|
|
if (!session) {
|
|
return NextResponse.redirect(new URL("/login", request.url));
|
|
}
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/((?!_next/static|_next/image|favicon\\.ico).*)"],
|
|
};
|