Discord: "Connect a Discord server" OAuth UI (server-26#225 S7)
- /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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e54191a5d0
commit
dd3e5c9655
@@ -0,0 +1,188 @@
|
||||
// "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`);
|
||||
}
|
||||
Reference in New Issue
Block a user