// Force a human approval screen on every MCP OAuth connection.//// Better Auth's MCP authorize endpoint only shows the consent page when the// request carries `prompt=consent` (it otherwise auto-issues an authorization// code — see better-auth/plugins/mcp/authorize). MCP clients don't send that,// so a connecting client would be granted a token with no human approval. This// wraps Better Auth's web handler and adds `consent` to the `prompt` of every// `GET /api/auth/mcp/authorize`, so — paired with `oidcConfig.consentPage` —// every connect is gated on the `/oauth/consent` approval screen.//// Pure + Effect-free; the wrapper is a plain Request -> Request transform so it// composes with whatever serves the Better Auth handler (prod + vite dev both// mount the same handler).const AUTHORIZE_PATH = "/api/auth/mcp/authorize";const CONSENT_PAGE = "/mcp-consent";/** Merge `consent` into a possibly-empty space-separated `prompt` value. */export const promptWithConsent = (prompt: string | null): string => { const set = new Set((prompt ?? "").split(/\s+/).filter((value) => value.length > 0)); set.add("consent"); return Array.from(set).join(" ");};/** * Return the MCP-authorize request with `prompt=consent` ensured, or the * original request unchanged when it isn't an MCP authorize call. */export const withForcedMcpConsent = (request: Request): Request => { if (request.method !== "GET") return request; const url = new URL(request.url); if (url.pathname !== AUTHORIZE_PATH) return request; const prompt = url.searchParams.get("prompt"); if (prompt && prompt.split(/\s+/).includes("consent")) return request; url.searchParams.set("prompt", promptWithConsent(prompt)); return new Request(url, request);};/** * If `location` is Better Auth's redirect to the consent page carrying a * `client_id` but no `client_name`, return that client id (so the caller can * look up the registered name and enrich the redirect). Otherwise null. * Better Auth's authorize only puts the opaque `client_id` on the consent * redirect; the registered name makes the approval screen legible. */export const consentRedirectClientId = (location: string | null): string | null => { if (!location) return null; const url = new URL(location, "http://host.internal"); if (url.pathname !== CONSENT_PAGE) return null; if (url.searchParams.get("client_name")) return null; return url.searchParams.get("client_id");};/** Append `client_name` to a consent-page redirect URL (path + query only). */export const withClientName = (location: string, clientName: string): string => { const url = new URL(location, "http://host.internal"); url.searchParams.set("client_name", clientName); return `${url.pathname}${url.search}`;};
// Force a human approval screen on every MCP OAuth connection.
//
// Better Auth's MCP authorize endpoint only shows the consent page when the
// request carries `prompt=consent` (it otherwise auto-issues an authorization
// code — see better-auth/plugins/mcp/authorize). MCP clients don't send that,
// so a connecting client would be granted a token with no human approval. This
// wraps Better Auth's web handler and adds `consent` to the `prompt` of every
// `GET /api/auth/mcp/authorize`, so — paired with `oidcConfig.consentPage` —
// every connect is gated on the `/oauth/consent` approval screen.
//
// Pure + Effect-free; the wrapper is a plain Request -> Request transform so it
// composes with whatever serves the Better Auth handler (prod + vite dev both
// mount the same handler).
const AUTHORIZE_PATH = "/api/auth/mcp/authorize";
const CONSENT_PAGE = "/mcp-consent";
/** Merge `consent` into a possibly-empty space-separated `prompt` value. */
export const promptWithConsent = (prompt: string | null): string => {
const set = new Set((prompt ?? "").split(/\s+/).filter((value) => value.length > 0));
set.add("consent");
return Array.from(set).join(" ");
};
/**
* Return the MCP-authorize request with `prompt=consent` ensured, or the
* original request unchanged when it isn't an MCP authorize call.
*/
export const withForcedMcpConsent = (request: Request): Request => {
if (request.method !== "GET") return request;
const url = new URL(request.url);
if (url.pathname !== AUTHORIZE_PATH) return request;
const prompt = url.searchParams.get("prompt");
if (prompt && prompt.split(/\s+/).includes("consent")) return request;
url.searchParams.set("prompt", promptWithConsent(prompt));
return new Request(url, request);
};
/**
* If `location` is Better Auth's redirect to the consent page carrying a
* `client_id` but no `client_name`, return that client id (so the caller can
* look up the registered name and enrich the redirect). Otherwise null.
* Better Auth's authorize only puts the opaque `client_id` on the consent
* redirect; the registered name makes the approval screen legible.
*/
export const consentRedirectClientId = (location: string | null): string | null => {
if (!location) return null;
const url = new URL(location, "http://host.internal");
if (url.pathname !== CONSENT_PAGE) return null;
if (url.searchParams.get("client_name")) return null;
return url.searchParams.get("client_id");
};
/** Append `client_name` to a consent-page redirect URL (path + query only). */
export const withClientName = (location: string, clientName: string): string => {
const url = new URL(location, "http://host.internal");
url.searchParams.set("client_name", clientName);
return `${url.pathname}${url.search}`;
};