diff --git a/src/server.ts b/src/server.ts index bd77b85..caeb032 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,6 +77,11 @@ async function handleConsent(req: IncomingMessage, res: ServerResponse, rawSearc const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); + // Raw query bytes (the URL API re-encodes .search, which breaks Better Auth's + // signed oauth_query check on /consent). Take the query straight from req.url. + const rawUrl = req.url ?? "/"; + const qIndex = rawUrl.indexOf("?"); + const rawSearch = qIndex >= 0 ? rawUrl.slice(qIndex) : ""; if (url.pathname !== "/healthz") { // Request-level trace (path only, no query — avoids logging codes/tokens). // eslint-disable-next-line no-console @@ -89,13 +94,25 @@ const server = createServer((req, res) => { return; } if (url.pathname === "/login" && req.method === "GET") { - void startLogin(req, res, url.search); + void startLogin(req, res, rawSearch); return; } if (url.pathname === "/consent" && req.method === "GET") { - void handleConsent(req, res, url.search); + void handleConsent(req, res, rawSearch); return; } + // Claude sends prompt=consent on the initial authorize, which forces the + // consent screen even with skipConsent. For a personal connector the login is + // the authorization, so strip it from the initial (unsigned) request — the + // signed resume after login has a `sig` and is left untouched. + if ( + url.pathname === "/api/auth/oauth2/authorize" && + !url.searchParams.has("sig") && + url.searchParams.get("prompt") === "consent" + ) { + url.searchParams.delete("prompt"); + req.url = url.pathname + (url.searchParams.toString() ? `?${url.searchParams}` : ""); + } // Everything else -> Better Auth (async handler). void handler(req, res); });