129 lines
8.0 KiB
Markdown
129 lines
8.0 KiB
Markdown
# 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.
|
|
|
|
### `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`. |
|
|
|
|
## 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 `:<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.
|