feat: Better Auth OAuth/OIDC server for the Intervals.icu MCP product
DCR-capable (RFC 7591) OAuth authorization server so Claude's MCP connector self-registers — the thing Authentik can't do until 2026.8.0. Users sign in with Google/Apple; oidcProvider + jwt issue asymmetric JWTs (JWKS) the MCP server verifies. Shares the existing CNPG Postgres (own `betterauth` schema). Scaffold: auth config, minimal Node server + login page, Dockerfile, CI.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
IMAGE: git.farh.net/farhoodlabs/intervalsicu-mcp-auth
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up Docker
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Log in to registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.farh.net -u cpfarhood --password-stdin
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
docker build -t "$IMAGE:${GITHUB_SHA}" -t "$IMAGE:latest" .
|
||||||
|
docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps (incl. the Better Auth CLI, used by the migration initContainer).
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 8080
|
||||||
|
USER node
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
Generated
+3408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "intervalsicu-mcp-auth",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Better Auth OAuth/OIDC server for the Intervals.icu MCP product — Google/Apple sign-in, DCR-capable, issues JWTs the MCP server verifies.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"migrate": "npx @better-auth/cli migrate -y",
|
||||||
|
"generate": "npx @better-auth/cli generate",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-auth": "^1.2.9",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@better-auth/cli": "^1.2.9",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.11.10",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Better Auth configuration — the Intervals.icu MCP product's identity provider.
|
||||||
|
*
|
||||||
|
* - Users sign in with Google / Apple (social providers).
|
||||||
|
* - `oidcProvider` makes this an OAuth/OIDC authorization server with **Dynamic
|
||||||
|
* Client Registration** (RFC 7591), so Claude's MCP connector self-registers.
|
||||||
|
* - `jwt` issues asymmetric-signed access tokens with a JWKS endpoint, which the
|
||||||
|
* MCP server verifies exactly like it verified Authentik.
|
||||||
|
*
|
||||||
|
* Identity note: the token `sub` is this service's user id. The MCP server and
|
||||||
|
* portal both key per-user Intervals credentials on that same id, so identity is
|
||||||
|
* consistent across the product (no Authentik involved).
|
||||||
|
*/
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { jwt, oidcProvider } from "better-auth/plugins";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
function required(name: string): string {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) throw new Error(`${name} is required`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialProviders: Record<string, unknown> = {};
|
||||||
|
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||||
|
socialProviders.google = {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (process.env.APPLE_CLIENT_ID && process.env.APPLE_CLIENT_SECRET) {
|
||||||
|
socialProviders.apple = {
|
||||||
|
clientId: process.env.APPLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.APPLE_CLIENT_SECRET,
|
||||||
|
appBundleIdentifier: process.env.APPLE_APP_BUNDLE_IDENTIFIER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Better Auth keeps its own tables in a dedicated schema so they sit cleanly
|
||||||
|
// alongside the MCP app's tables in the same CNPG database.
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: required("AUTH_DATABASE_URL"),
|
||||||
|
options: "-c search_path=betterauth,public",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
// Public origin incl. the gateway path prefix (e.g. https://host/auth). The
|
||||||
|
// gateway strips /auth before forwarding, so Better Auth's own routing uses
|
||||||
|
// its default basePath while generated URLs/issuer keep the /auth prefix.
|
||||||
|
baseURL: required("BETTER_AUTH_URL"),
|
||||||
|
secret: required("BETTER_AUTH_SECRET"),
|
||||||
|
trustedOrigins: (process.env.TRUSTED_ORIGINS ?? "").split(",").filter(Boolean),
|
||||||
|
database: pool,
|
||||||
|
socialProviders,
|
||||||
|
plugins: [
|
||||||
|
jwt(),
|
||||||
|
oidcProvider({
|
||||||
|
loginPage: "/login",
|
||||||
|
allowDynamicClientRegistration: true,
|
||||||
|
useJWTPlugin: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 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(params: URLSearchParams): string {
|
||||||
|
// Preserve wherever the OAuth flow wants to return to after login.
|
||||||
|
const cb = params.get("callbackURL") || params.get("redirect") || "/";
|
||||||
|
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") {
|
||||||
|
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.searchParams));
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user