- /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>
189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
// "Connect a Discord server" — per-bot OAuth (server-26#225 S6 backend, S7 UI).
|
|
// Kept out of c2api.ts on purpose (plan S7): this is the only browser code that
|
|
// handles a Discord authorization code, and it should stay small and readable.
|
|
//
|
|
// Flow: an org owner picks a radio bot → POST /discord/oauth/start returns
|
|
// Discord's authorize URL → the owner adds the bot in Discord → Discord sends
|
|
// the browser to /discord/connected?code=…&state=… → that page calls
|
|
// POST /discord/oauth/complete. The bot's client secret never reaches the
|
|
// browser: the code exchange happens in c2-core.
|
|
|
|
import { auth } from "@/lib/firebase";
|
|
|
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
|
|
|
/** Same error format as c2api's request(), so lib/discord.parseC2Error reads it. */
|
|
async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: object): Promise<T> {
|
|
const user = auth.currentUser;
|
|
const token = user ? await user.getIdToken() : null;
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
const post = <T,>(path: string, body: object) => send<T>("POST", path, body);
|
|
|
|
/** Which bot to invite. Empty = c2-core picks (org's own bot, else a DRB bot with room). */
|
|
export interface OAuthStartRequest {
|
|
/** One of the org's own (BYO) bots, by token_id. */
|
|
token_id?: string;
|
|
/** The bot marked preferred for this system (works for a DRB bot a platform admin pinned too). */
|
|
system_id?: string;
|
|
/** Kept for the plan's node-page entry point: the preferred bot of this node's system. */
|
|
node_id?: string;
|
|
/** Where the result card's button goes. Relative path only; c2-core re-checks it. */
|
|
return_to?: string;
|
|
}
|
|
|
|
export interface OAuthStartResult {
|
|
url: string;
|
|
bot_name: string | null;
|
|
}
|
|
|
|
/** Status 404 from /oauth/start: no bot that fits has Connect turned on (no client secret set). */
|
|
export class OAuthNotConfigured extends Error {}
|
|
|
|
export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthStartResult> {
|
|
let raw: { url?: string; authorize_url?: string; bot_name?: string | null; token_name?: string | null };
|
|
try {
|
|
raw = await post("/discord/oauth/start", req);
|
|
} catch (err) {
|
|
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message);
|
|
throw err;
|
|
}
|
|
const url = raw.url ?? raw.authorize_url ?? "";
|
|
// Only ever send the browser to Discord from here — never to a URL that
|
|
// merely came back in a response body without being Discord's.
|
|
if (!isDiscordAuthorizeUrl(url)) throw new Error("C2 returned an unexpected sign-in link. Nothing was changed.");
|
|
return { url, bot_name: raw.bot_name ?? raw.token_name ?? null };
|
|
}
|
|
|
|
export function isDiscordAuthorizeUrl(url: string): boolean {
|
|
try {
|
|
const u = new URL(url);
|
|
return u.protocol === "https:" && (u.hostname === "discord.com" || u.hostname === "discordapp.com") &&
|
|
u.pathname.replace(/\/+$/, "").endsWith("/oauth2/authorize");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export interface OAuthCompleteResult {
|
|
guild_id: string | null;
|
|
guild_name: string | null;
|
|
/** Voice channels at least one radio bot can join right now. null = the check didn't finish in time. */
|
|
channels_ready: number | null;
|
|
/** Voice channels where every bot is missing a permission. */
|
|
channels_blocked: number | null;
|
|
return_to: string | null;
|
|
bot_name: string | null;
|
|
/** Streams stopped because this server used to act for another (fallback) org. */
|
|
stopped: number;
|
|
}
|
|
|
|
function count(v: unknown): number | null {
|
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
if (Array.isArray(v)) return v.length;
|
|
return null;
|
|
}
|
|
|
|
export async function completeDiscordOAuth(code: string, state: string): Promise<OAuthCompleteResult> {
|
|
const r = await post<Record<string, unknown>>("/discord/oauth/complete", { code, state });
|
|
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
|
return {
|
|
guild_id: str(r.guild_id),
|
|
guild_name: str(r.guild_name),
|
|
channels_ready: count(r.channels_ready),
|
|
channels_blocked: count(r.channels_blocked),
|
|
return_to: safeReturnTo(str(r.return_to)),
|
|
bot_name: str(r.bot_name) ?? str(r.token_name),
|
|
stopped: count(r.stopped) ?? 0,
|
|
};
|
|
}
|
|
|
|
/** A same-site path like "/nodes/abc" — never "//host", a scheme, or a backslash trick. */
|
|
export function safeReturnTo(p: string | null | undefined): string | null {
|
|
if (!p || p.length > 200) return null;
|
|
return /^\/(?![/\\])[A-Za-z0-9/_\-.?=&%]*$/.test(p) ? p : null;
|
|
}
|
|
|
|
// --- the callback's code/state, held across a sign-in detour --------------
|
|
|
|
/** The page Discord redirects to. Registered as the redirect URI on each bot app. */
|
|
export const OAUTH_CALLBACK_PATH = "/discord/connected";
|
|
|
|
const STASH_KEY = "drb_discord_oauth_cb";
|
|
const STASH_TTL_MS = 10 * 60 * 1000; // c2-core's state lives 10 min (discord_oauth_state_ttl_seconds)
|
|
|
|
export interface OAuthCallback {
|
|
code: string | null;
|
|
state: string | null;
|
|
/** Discord's error param, e.g. "access_denied" when the user pressed Cancel. */
|
|
error: string | null;
|
|
at: number;
|
|
}
|
|
|
|
/** Read the callback params off the URL (if any), stash them in this tab, and
|
|
* strip them from the address bar so they never end up in history or a Referer. */
|
|
export function captureCallback(): OAuthCallback | null {
|
|
if (typeof window === "undefined") return null;
|
|
const q = new URLSearchParams(window.location.search);
|
|
const code = q.get("code");
|
|
const state = q.get("state");
|
|
const error = q.get("error");
|
|
if (code || state || error) {
|
|
const cb: OAuthCallback = { code, state, error, at: Date.now() };
|
|
try {
|
|
sessionStorage.setItem(STASH_KEY, JSON.stringify(cb));
|
|
} catch {
|
|
// Private mode / storage blocked: we still have it in memory for this load.
|
|
}
|
|
window.history.replaceState(null, "", OAUTH_CALLBACK_PATH);
|
|
return cb;
|
|
}
|
|
try {
|
|
const raw = sessionStorage.getItem(STASH_KEY);
|
|
if (!raw) return null;
|
|
const cb = JSON.parse(raw) as OAuthCallback;
|
|
if (typeof cb?.at !== "number" || Date.now() - cb.at > STASH_TTL_MS) {
|
|
sessionStorage.removeItem(STASH_KEY);
|
|
return null;
|
|
}
|
|
return cb;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clearCallback(): void {
|
|
try {
|
|
sessionStorage.removeItem(STASH_KEY);
|
|
} catch {
|
|
// nothing to clear
|
|
}
|
|
}
|
|
|
|
// --- platform admin: a bot's OAuth client secret ---------------------------
|
|
|
|
/**
|
|
* PUT /tokens/{id}/oauth (platform admin). Write-only: c2-core checks the
|
|
* secret with Discord (client-credentials grant) and stores it server-side
|
|
* in bot_oauth/{token_id}; it is never returned, logged, or shown again.
|
|
*/
|
|
export function setBotOAuthSecret(tokenId: string, clientSecret: string): Promise<{ ok?: boolean; oauth_enabled?: boolean }> {
|
|
return send("PUT", `/tokens/${encodeURIComponent(tokenId)}/oauth`, { client_secret: clientSecret });
|
|
}
|
|
|
|
/** DELETE /tokens/{id}/oauth (platform admin): deletes the stored secret; Connect is off for that bot. */
|
|
export function clearBotOAuthSecret(tokenId: string): Promise<unknown> {
|
|
return send("DELETE", `/tokens/${encodeURIComponent(tokenId)}/oauth`);
|
|
}
|