Files
intervalsicu-mcp-auth/CLAUDE.md
T
Chris Farhood 46bf005ec3
build / test (push) Failing after 12m52s
build / build (push) Has been skipped
cleanup: remove DEBUG_EMAIL_PASSWORD email/password backdoor; add MIT LICENSE + .env.example
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:53:53 -04:00

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 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/*.tsdist/*.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.

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 302s 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 302s 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

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 cinpm 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 :<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.