Closes the audience-binding gap (RFC 9068): tokens minted by the issuer for a
different resource are now rejected at /mcp, and subject-less tokens are refused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Better Auth signs access tokens with EdDSA (Ed25519), not RS256. Accept EdDSA
(+ RS256/ES256), and validate issuer + signature + expiry strictly while checking
audience softly — single-resource server behind a dedicated AS with dynamic DCR
client ids, so issuer + signature is the trust boundary. Adds an EdDSA test.
All 20 tools now drop the athlete_id/api_key parameters and instead resolve the
authenticated caller's stored, enabled credentials via
credentials.resolve_caller_credentials() (get_access_token().subject -> store).
Security: there is no tool parameter a caller can pass to supply a key, so a
disabled/unapproved user cannot bypass the admin-approval gate — each tool
returns a helpful "not approved / set up your credentials" message instead.
Gear resolution now uses the caller's athlete id rather than an env var.
Tests: conftest autouse fixture runs tool tests as an enabled user; a
parametrized test asserts every tool refuses when unauthorized; existing tool
tests updated (no more athlete_id/api_key kwargs). 221 passing at 91.5%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for multi-user operation (Phase 1):
- crypto.py: AES-256-GCM encrypt/decrypt for the per-user Intervals API key,
key from INTERVALS_ENC_KEY (base64 32 bytes). Random nonce per message.
- db/models.py + db/session.py: SQLAlchemy 2.0 async User model (keyed on the
Authentik sub; api_key stored encrypted; enabled = admin-approval gate) and a
lazy async engine/sessionmaker from DATABASE_URL.
- store.py: async CRUD. New users created disabled; login never flips enabled;
get_active_credentials returns decrypted creds only for an enabled user that
has them.
- credentials.py: resolve_caller_credentials() maps get_access_token().subject
to that user's stored creds, falling back to env config only when unauthenticated
(stdio/local).
- Tests (SQLite in-memory, no infra): crypto round-trip/tamper, store gating,
resolver paths. Suite 199 passing at 90.4% (gate holds).
Deps: sqlalchemy[asyncio], asyncpg, alembic (+ aiosqlite for tests). Pin py3.12.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump mcp[cli] 1.22 -> 1.28.1 (negotiates MCP protocol 2025-11-25, matching
current Claude clients; the old 2025-06-18 server never got a tools/list on
the connector surface).
- Bake transport config into code: stateless_http + json_response for HTTP
(single JSON body instead of a 34KB SSE stream, which the connector pipeline
handles far more reliably).
- Bake Authentik OAuth (AuthSettings + JWT TokenVerifier) into intervals_mcp_server.auth,
configured from MCP_ISSUER/MCP_RESOURCE/MCP_JWKS_URI/MCP_CLIENT_ID — removes the
runtime FastMCP.__init__ monkeypatch from the k8s deployment command.
- Accept token audience with/without trailing slash (RFC 8707 clients use the
slash-normalised resource metadata value).
- Dockerfile CMD runs the module (transport via MCP_TRANSPORT); add .gitea CI to
build+push the image to git.farh.net/farhoodlabs/intervalsicu-mcp.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>