auth: server-side /login redirect to Google (no interactive JS page)
build / test (push) Successful in 10s
build / build (push) Failing after 13s

Claude's OAuth window doesn't run page JS, so the button never fired. Initiate
Google sign-in server-side via auth.api.signInSocial and 302 straight to Google;
after Google, callbackURL returns to /oauth2/authorize and the flow resumes. Pure
redirect chain, no client JS.
This commit is contained in:
2026-07-05 22:59:32 -04:00
parent b6c0751687
commit cfac41d774
+31 -41
View File
@@ -5,51 +5,42 @@
* JWKS, discovery) are served by its node handler. We add a tiny login page * JWKS, discovery) are served by its node handler. We add a tiny login page
* (the OIDC `loginPage` target) with Google/Apple buttons, and a health check. * (the OIDC `loginPage` target) with Google/Apple buttons, and a health check.
*/ */
import { createServer } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth.js"; import { auth } from "./auth.js";
const PORT = Number(process.env.PORT ?? 8080); const PORT = Number(process.env.PORT ?? 8080);
const handler = toNodeHandler(auth); const handler = toNodeHandler(auth);
function loginPage(rawSearch: string): string { // Better Auth's oauth-provider redirects here (its loginPage) with the original
// Better Auth's oauth-provider redirects here with the original, signed // signed authorize params in the query. Rather than serve an interactive HTML
// authorize params in the query. After sign-in we must hand them back to the // page (Claude's OAuth window doesn't run our JS), initiate Google sign-in
// authorize endpoint verbatim so it resumes and issues the code. If there's no // server-side and 302 straight to Google. After Google, Better Auth returns to
// OAuth context (a stray visit), fall back to the portal. // the authorize endpoint (via callbackURL) and resumes — a pure redirect chain
const cb = rawSearch ? "/api/auth/oauth2/authorize" + rawSearch : "/portal"; // that any OAuth-following client handles. Google is the only provider for now.
const social = (provider: string, label: string) => ` async function startLogin(req: IncomingMessage, res: ServerResponse, rawSearch: string) {
<button data-provider="${provider}" class="btn">${label}</button>`; const callbackURL = rawSearch ? "/api/auth/oauth2/authorize" + rawSearch : "/portal";
return `<!doctype html> try {
<html lang="en"><head><meta charset="utf-8"> const { headers, response } = await auth.api.signInSocial({
<meta name="viewport" content="width=device-width, initial-scale=1"> body: { provider: "google", callbackURL },
<title>Sign in — Intervals.icu MCP</title> returnHeaders: true,
<style> });
body{font-family:system-ui,sans-serif;max-width:22rem;margin:6rem auto;padding:0 1rem;color:#111} const setCookie = headers.getSetCookie();
h1{font-size:1.25rem} .btn{display:block;width:100%;padding:.75rem;margin:.5rem 0;font-size:1rem; if (setCookie.length) res.setHeader("set-cookie", setCookie);
border:1px solid #ccc;border-radius:.5rem;background:#fff;cursor:pointer} const target = (response as { url?: string } | null)?.url;
.btn:hover{background:#f5f5f5} .muted{color:#666;font-size:.85rem} if (!target) {
</style></head><body> res.writeHead(500, { "content-type": "text/plain" });
<h1>Sign in to Intervals.icu MCP</h1> res.end("Could not start sign-in.");
<p class="muted">Connect your Intervals.icu account to use it from Claude.</p> return;
${auth.options.socialProviders && "google" in auth.options.socialProviders ? social("google", "Continue with Google") : ""} }
${auth.options.socialProviders && "apple" in auth.options.socialProviders ? social("apple", "Continue with Apple") : ""} res.writeHead(302, { location: target });
<script> res.end();
const cb = ${JSON.stringify(cb)}; } catch (err) {
for (const b of document.querySelectorAll(".btn")) { // eslint-disable-next-line no-console
b.addEventListener("click", async () => { console.error("[login] signInSocial failed", err);
b.disabled = true; res.writeHead(500, { "content-type": "text/plain" });
const r = await fetch("api/auth/sign-in/social", { res.end("Sign-in error.");
method: "POST", }
headers: { "content-type": "application/json" },
body: JSON.stringify({ provider: b.dataset.provider, callbackURL: cb }),
});
const data = await r.json().catch(() => ({}));
if (data.url) location.href = data.url; else { b.disabled = false; alert("Sign-in failed"); }
});
}
</script>
</body></html>`;
} }
const server = createServer((req, res) => { const server = createServer((req, res) => {
@@ -66,8 +57,7 @@ const server = createServer((req, res) => {
return; return;
} }
if (url.pathname === "/login" && req.method === "GET") { if (url.pathname === "/login" && req.method === "GET") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); void startLogin(req, res, url.search);
res.end(loginPage(url.search));
return; return;
} }
// Everything else -> Better Auth (async handler). // Everything else -> Better Auth (async handler).