From 534bba3b067a194631536c3df6fbfeb40187d8b1 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 7 Jul 2026 07:21:24 -0400 Subject: [PATCH] docs: add CLAUDE.md + README Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 70 +++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..faecf91 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`intervalsicu-mcp-auth` is the **OAuth 2.1 / OIDC authorization server (IdP)** for the self-hosted, +multi-tenant Intervals.icu MCP product. It is built on [Better Auth](https://better-auth.com) +(TypeScript, plain Node `http` server) and served under `/api/auth` (plus custom `/login` and +`/consent` routes) at `https://intervalsicu.farhoodlabs.com`. + +It is one of three services sharing a single host and a single CloudNativePG Postgres database: + +- **intervalsicu-mcp** — Python/FastMCP MCP server at `/mcp`. Verifies *this* server's EdDSA-signed + JWT access tokens against its JWKS (RFC 9068 style) and resolves per-user Intervals.icu credentials + by the token `sub`. +- **intervalsicu-mcp-ui** — Python portal at `/portal`. An OIDC client of *this* auth server. +- **intervalsicu-mcp-auth** (this repo) — the authorization server both of the above depend on. + +Users sign in with Google (Apple optional). The token `sub` is *this* service's user id; the MCP +server and the portal both key per-user Intervals credentials on that id, so identity is consistent +across the product. There is no Authentik in this design. + +## Architecture + +Three source files, compiled `src/*.ts` → `dist/*.js` (NodeNext ESM, `.js` import specifiers). + +### `src/auth.ts` — the Better Auth config (`export const auth`, `export const pool`) + +- **Plugin order matters: `jwt()` is listed before `oauthProvider()`.** The oauth-provider plugin + looks up the `jwt` plugin to issue JWKS-verifiable (asymmetric) access tokens and errors if it + isn't already registered — so `jwt()` must come first, explicitly. +- `oauthProvider({...})` (from `@better-auth/oauth-provider`, the current OAuth 2.1 provider that + replaces the deprecated `oidc-provider`) options actually set: + - `validAudiences` — from `VALID_AUDIENCES` (comma-separated), defaulting to the three + `intervalsicu.farhoodlabs.com` MCP URLs. **This is load-bearing:** MCP clients send the + RFC 8707 `resource` (the MCP server URL); it must be an allowed audience or the token exchange + is rejected. Listing it here also makes the access token a JWT with `aud=resource` (RFC 9068), + which is exactly what the MCP server verifies. + - `loginPage` = `${baseURL}/login` (absolute, because some clients resolve a relative redirect + against their own origin and land on a blank page). + - `consentPage` = `${baseURL}/consent` (required by the type, but not normally shown — see below). + - `skipConsent: true` — for a personal MCP connector, signing in *is* the authorization. + - `allowDynamicClientRegistration: true` — RFC 7591 DCR so Claude's MCP connector self-registers. + - `allowUnauthenticatedClientRegistration: true` — required because MCP clients register *before* + they have any credentials. +- `socialProviders` — env-gated: `google` added only if `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` + are set; `apple` added only if `APPLE_CLIENT_ID`/`APPLE_CLIENT_SECRET` are set. +- `pool` — a shared `pg.Pool` from `AUTH_DATABASE_URL`. This is the same CNPG database the rest of + the product uses; Better Auth's tables (`user`, `session`, `account`, `jwks`, `oauthClient`, ...) + coexist with the app's own `users` table in the default schema. +- **`emailAndPassword` is DEBUG-ONLY scaffolding.** Enabled only when `DEBUG_EMAIL_PASSWORD=true`; + it exists solely to mint a session headlessly for debugging the OAuth token exchange without a + browser. **It must be off in normal operation.** + +### `src/server.ts` — the Node HTTP server + +Creates a plain `http` server on `PORT` (default 8080). Most paths delegate to Better Auth's +`toNodeHandler(auth)` (social sign-in, OAuth2/OIDC, DCR `/oauth2/register`, JWKS, discovery). +Custom handling: + +- **`GET /login`** — the `loginPage` target. Instead of serving interactive HTML (Claude's OAuth + popup does not run our page JS), it calls `auth.api.signInSocial({ provider: "google" })` + server-side and `302`s straight to Google, with `callbackURL` set to the original signed authorize + query so Better Auth resumes the authorize flow after Google returns. A pure redirect chain any + OAuth-following client handles. Google is the only provider wired here. +- **`GET /consent`** — auto-approves. Better Auth redirects here when the client sends + `prompt=consent` (which forces consent even with `skipConsent`). Calls + `auth.api.oauth2Consent({ accept: true, oauth_query, ... })` with the user's session cookie and + `302`s to the client callback. **Uses the raw query bytes from `req.url`** (not the WHATWG `URL` + API, which re-encodes `.search` and breaks Better Auth's signed `oauth_query` check). +- **`prompt=consent` stripping on initial authorize** — for `/api/auth/oauth2/authorize` requests + that are *unsigned* (no `sig`) and carry `prompt=consent` (Claude sends this on the initial + authorize), the param is deleted before handing to Better Auth so the flow doesn't force a consent + screen. The signed resume after login has a `sig` and is left untouched. +- **`GET /healthz`** — returns `{"status":"ok"}`. +- **Request logging** — logs `[req] METHOD path` / `[res] METHOD path -> status` for every request + except `/healthz`, path only (query is never logged, to avoid leaking codes/tokens). + +### `src/migrate.ts` — schema migration + portal client seed (run as an initContainer) + +- Applies Better Auth's schema via `getMigrations(auth.options)` using the same core version the app + runs on (no CLI, no version drift). Idempotent — a no-op once the schema is current. +- Then **seeds the portal as a stable public (PKCE) OIDC client** via a raw `INSERT ... ON CONFLICT` + into `oauthClient`: `clientId` = `PORTAL_CLIENT_ID` (default `intervalsicu-portal`), no secret + (`public=true`, `requirePKCE=true`, `tokenEndpointAuthMethod='none'`), `redirectUris` = + `PORTAL_REDIRECT_URI` (default `https://intervalsicu.farhoodlabs.com/portal/auth/callback`), + grants `authorization_code`+`refresh_token`, response type `code`, scopes `openid email profile`, + `skipConsent=true`. Idempotent (upserts the redirect/PKCE fields on conflict). + +## Commands + +```bash +npm ci # install (CI and Docker both use this) +npm run typecheck # tsc --noEmit (this is the CI gate; there are no tests) +npm run build # tsc -> dist/ +npm run dev # tsx watch src/server.ts (local hot-reload) +npm run migrate # node dist/migrate.js — apply schema + seed portal client +npm start # node dist/server.js — run the compiled server +``` + +There is **no test suite**; the CI quality gate is `npm run typecheck`. `strict` is on in +`tsconfig.json`. + +## Environment variables + +Read from the code: + +| Var | Required | Purpose | +| --- | --- | --- | +| `BETTER_AUTH_URL` | yes | Public base URL; used as `baseURL` and to build `loginPage`/`consentPage`. | +| `BETTER_AUTH_SECRET` | yes | Better Auth signing secret. | +| `AUTH_DATABASE_URL` | yes | Postgres connection string (shared CNPG DB). | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | for Google | Enables Google social login when both set. | +| `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` (`APPLE_APP_BUNDLE_IDENTIFIER`) | for Apple | Enables Apple social login when both set. | +| `VALID_AUDIENCES` | no (has default) | Comma-separated allowed token audiences / RFC 8707 resources. | +| `TRUSTED_ORIGINS` | no | Comma-separated trusted origins for Better Auth. | +| `LOG_LEVEL` | no (`info`) | Better Auth logger level (`debug`/`info`). | +| `PORT` | no (`8080`) | HTTP listen port. | +| `PORTAL_CLIENT_ID` / `PORTAL_REDIRECT_URI` | no (have defaults) | Portal OIDC client seeded by `migrate.ts`. | +| `DEBUG_EMAIL_PASSWORD` | no | **Debug only.** `=true` enables email/password auth for headless debugging. Keep off. | + +## Deployment + +- Docker image built from `node:22-alpine` (`npm ci` → `npm run build`, runs `node dist/server.js` + as user `node`, exposes 8080). +- **Gitea CI** (`.gitea/workflows/build.yaml`, on push to `main`): job `test` runs + `npm ci && npm run typecheck`; job `build` builds and pushes the image to + `git.farh.net/farhoodlabs/intervalsicu-mcp-auth` (`:latest` and `:`). +- **Flux** (GitOps) deploys the pushed image on Kubernetes. The migration runs as an initContainer + (`node dist/migrate.js`), mirroring the MCP server's Alembic step. Served under `/api/auth` + (+ `/login`, `/consent`) behind the cluster gateway. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc75eec --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# intervalsicu-mcp-auth + +The **OAuth 2.1 / OIDC authorization server** (identity provider) for the self-hosted, multi-tenant +Intervals.icu MCP product. Built on [Better Auth](https://better-auth.com) (TypeScript, plain Node +HTTP server), served at `https://intervalsicu.farhoodlabs.com/api/auth`. + +## Role in the system + +Three services share one host and one CloudNativePG Postgres database: + +- **intervalsicu-mcp** — Python/FastMCP MCP server at `/mcp`. Verifies this server's EdDSA-signed + JWT access tokens against its JWKS, then resolves per-user Intervals.icu credentials by the token + `sub`. +- **intervalsicu-mcp-ui** — Python portal at `/portal`. An OIDC client of this auth server. +- **intervalsicu-mcp-auth** (this repo) — the authorization server both depend on. + +Users sign in with **Google** (Apple optional). This server: + +- Provides **Dynamic Client Registration** (RFC 7591) so Claude's MCP connector self-registers. +- Uses **PKCE**. +- Issues **EdDSA-signed JWT access tokens** with a JWKS endpoint, so the MCP server verifies them + RFC 9068 style (with `aud` = the requested `resource`). +- The token `sub` is this service's user id — the shared identity the MCP server and portal both key + per-user Intervals credentials on. + +## Layout + +- `src/auth.ts` — Better Auth config: `jwt()` + `oauthProvider()` plugins, Google/Apple social + providers (env-gated), shared `pg.Pool`. +- `src/server.ts` — Node HTTP server; custom `/login`, `/consent`, `/healthz` routes, everything + else delegated to Better Auth. +- `src/migrate.ts` — applies Better Auth's schema and seeds the portal OIDC client (run as a k8s + initContainer). + +See [`CLAUDE.md`](./CLAUDE.md) for the detailed architecture (why `jwt()` comes before +`oauthProvider()`, why `validAudiences` matters, and why `/login` and `/consent` are custom routes). + +## Development + +```bash +npm ci # install +npm run typecheck # tsc --noEmit (the CI gate; there are no tests) +npm run build # tsc -> dist/ +npm run dev # tsx watch src/server.ts (hot reload) +npm run migrate # apply schema + seed the portal client +npm start # node dist/server.js +``` + +## Key environment variables + +| Var | Notes | +| --- | --- | +| `BETTER_AUTH_URL` | **required** — public base URL. | +| `BETTER_AUTH_SECRET` | **required** — signing secret. | +| `AUTH_DATABASE_URL` | **required** — Postgres (shared CNPG DB). | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | enable Google login. | +| `APPLE_CLIENT_ID` / `APPLE_CLIENT_SECRET` | enable Apple login (optional). | +| `VALID_AUDIENCES` | comma-separated allowed token audiences / RFC 8707 resources (has default). | +| `TRUSTED_ORIGINS` | comma-separated trusted origins. | +| `LOG_LEVEL` | `debug` / `info` (default `info`). | +| `PORT` | HTTP port (default `8080`). | +| `PORTAL_CLIENT_ID` / `PORTAL_REDIRECT_URI` | portal client seeded by `migrate.ts` (have defaults). | +| `DEBUG_EMAIL_PASSWORD` | **debug only** — `=true` enables email/password auth for headless OAuth debugging. Keep off in normal operation. | + +## Deployment + +Push to `main` triggers Gitea CI (`git.farh.net`): typecheck, then build and push the Docker image +to `git.farh.net/farhoodlabs/intervalsicu-mcp-auth`. Flux (GitOps) deploys it on Kubernetes, running +`node dist/migrate.js` as an initContainer. Served under `/api/auth` (+ `/login`, `/consent`) behind +the cluster gateway.