8.0 KiB
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
(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 tokensub. - 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 beforeoauthProvider(). The oauth-provider plugin looks up thejwtplugin to issue JWKS-verifiable (asymmetric) access tokens and errors if it isn't already registered — sojwt()must come first, explicitly. oauthProvider({...})(from@better-auth/oauth-provider, the current OAuth 2.1 provider that replaces the deprecatedoidc-provider) options actually set:validAudiences— fromVALID_AUDIENCES(comma-separated), defaulting to the threeintervalsicu.farhoodlabs.comMCP URLs. This is load-bearing: MCP clients send the RFC 8707resource(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 withaud=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:googleadded only ifGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETare set;appleadded only ifAPPLE_CLIENT_ID/APPLE_CLIENT_SECRETare set.pool— a sharedpg.PoolfromAUTH_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 ownuserstable in the default schema.
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— theloginPagetarget. Instead of serving interactive HTML (Claude's OAuth popup does not run our page JS), it callsauth.api.signInSocial({ provider: "google" })server-side and302s straight to Google, withcallbackURLset 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 sendsprompt=consent(which forces consent even withskipConsent). Callsauth.api.oauth2Consent({ accept: true, oauth_query, ... })with the user's session cookie and302s to the client callback. Uses the raw query bytes fromreq.url(not the WHATWGURLAPI, which re-encodes.searchand breaks Better Auth's signedoauth_querycheck).prompt=consentstripping on initial authorize — for/api/auth/oauth2/authorizerequests that are unsigned (nosig) and carryprompt=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 asigand is left untouched.GET /healthz— returns{"status":"ok"}.- Request logging — logs
[req] METHOD path/[res] METHOD path -> statusfor 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 CONFLICTintooauthClient:clientId=PORTAL_CLIENT_ID(defaultintervalsicu-portal), no secret (public=true,requirePKCE=true,tokenEndpointAuthMethod='none'),redirectUris=PORTAL_REDIRECT_URI(defaulthttps://intervalsicu.farhoodlabs.com/portal/auth/callback), grantsauthorization_code+refresh_token, response typecode, scopesopenid email profile,skipConsent=true. Idempotent (upserts the redirect/PKCE fields on conflict).
Commands
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. |
Deployment
- Docker image built from
node:22-alpine(npm ci→npm run build, runsnode dist/server.jsas usernode, exposes 8080). - Gitea CI (
.gitea/workflows/build.yaml, on push tomain): jobtestrunsnpm ci && npm run typecheck; jobbuildbuilds and pushes the image togit.farh.net/farhoodlabs/intervalsicu-mcp-auth(:latestand:<sha>). - 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.