81 lines
3.5 KiB
TypeScript
81 lines
3.5 KiB
TypeScript
/**
|
|
* Minimal Node HTTP server hosting Better Auth.
|
|
*
|
|
* All Better Auth routes (social sign-in, OAuth2/OIDC, DCR `/oauth2/register`,
|
|
* 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.
|
|
*/
|
|
import { createServer } from "node:http";
|
|
import { toNodeHandler } from "better-auth/node";
|
|
import { auth } from "./auth.js";
|
|
|
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
const handler = toNodeHandler(auth);
|
|
|
|
function loginPage(rawSearch: string): string {
|
|
// Better Auth's oauth-provider redirects here with the original, signed
|
|
// authorize params in the query. After sign-in we must hand them back to the
|
|
// authorize endpoint verbatim so it resumes and issues the code. If there's no
|
|
// OAuth context (a stray visit), fall back to the portal.
|
|
const cb = rawSearch ? "/api/auth/oauth2/authorize" + rawSearch : "/portal";
|
|
const social = (provider: string, label: string) => `
|
|
<button data-provider="${provider}" class="btn">${label}</button>`;
|
|
return `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sign in — Intervals.icu MCP</title>
|
|
<style>
|
|
body{font-family:system-ui,sans-serif;max-width:22rem;margin:6rem auto;padding:0 1rem;color:#111}
|
|
h1{font-size:1.25rem} .btn{display:block;width:100%;padding:.75rem;margin:.5rem 0;font-size:1rem;
|
|
border:1px solid #ccc;border-radius:.5rem;background:#fff;cursor:pointer}
|
|
.btn:hover{background:#f5f5f5} .muted{color:#666;font-size:.85rem}
|
|
</style></head><body>
|
|
<h1>Sign in to Intervals.icu MCP</h1>
|
|
<p class="muted">Connect your Intervals.icu account to use it from Claude.</p>
|
|
${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") : ""}
|
|
<script>
|
|
const cb = ${JSON.stringify(cb)};
|
|
for (const b of document.querySelectorAll(".btn")) {
|
|
b.addEventListener("click", async () => {
|
|
b.disabled = true;
|
|
const r = await fetch("api/auth/sign-in/social", {
|
|
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 url = new URL(req.url ?? "/", "http://localhost");
|
|
if (url.pathname !== "/healthz") {
|
|
// Request-level trace (path only, no query — avoids logging codes/tokens).
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[req] ${req.method} ${url.pathname}`);
|
|
res.on("finish", () => console.log(`[res] ${req.method} ${url.pathname} -> ${res.statusCode}`));
|
|
}
|
|
if (url.pathname === "/healthz") {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end('{"status":"ok"}');
|
|
return;
|
|
}
|
|
if (url.pathname === "/login" && req.method === "GET") {
|
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
res.end(loginPage(url.search));
|
|
return;
|
|
}
|
|
// Everything else -> Better Auth (async handler).
|
|
void handler(req, res);
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`intervalsicu-mcp-auth listening on :${PORT}`);
|
|
});
|