Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 (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 tokensub. - 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 requestedresource). - The token
subis 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), sharedpg.Pool.src/server.ts— Node HTTP server; custom/login,/consent,/healthzroutes, 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 for the detailed architecture (why jwt() comes before
oauthProvider(), why validAudiences matters, and why /login and /consent are custom routes).
Development
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). |
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.