Hand-roll OAuth 2.1 for MCP spec compliance
Context
The MCP specification requires OAuth 2.1 with PKCE (RFC 7636), dynamic client registration (RFC 7591), and protected resource metadata (RFC 9728). Access tokens are PASETO tokens from our existing token infrastructure (ADR-0028), and authentication goes through our passkey/password flow (ADR-0058).
No Rust crate covers this combination. oxide-auth is the closest — it handles authorization code + PKCE but doesn't support dynamic client registration, protected resource metadata, or plugging in a custom token format. You'd be fighting the crate's token model to get PASETO tokens out the other side. The other options (openid, oauth2-rs) are client libraries, not authorization server implementations.
Claude produced the initial implementation in a single pass. During code review I checked whether we should be using an existing crate instead. Nothing in the Rust ecosystem fit, so I focused on verification: test coverage ported from existing crate documentation, integration specs covering the full authorization flow, and a line-by-line comparison against the RFCs.
Decision
Build a minimal OAuth 2.1 authorization server from scratch, implementing only what the MCP spec requires:
- Authorization code flow with mandatory PKCE (S256 only)
- Dynamic client registration with IP rate limiting
- Protected resource metadata endpoint
- Login via passkey (WebAuthn challenge/response) or email/password fallback, with a consent screen
- Refresh token rotation with 90-day expiry
- Ephemeral state in Redis (auth codes, client registrations, challenges) — no new database tables
Access tokens are PASETO tokens issued through the existing TokenService, so MCP clients authenticate the same way iOS does. The client_id doubles as the device_id for device binding.
26 integration tests cover the surface: PKCE verification, single-use auth codes, refresh token rotation, client registration validation, code verifier length/charset enforcement per RFC 7636, and a full end-to-end flow test that walks through registration, login, consent, token exchange, API access, refresh, and replay prevention.
Consequences
This is an experiment. The build-vs-buy equation is different when an LLM can produce a working RFC implementation in a single pass — but we don't know how different yet. The implementation is ~600 lines across 8 modules with good test coverage, which is small enough to audit by hand and maintain directly. Whether it stays that way as edge cases accumulate is an open question.
We own every line, which means we own every bug. No upstream security patches. The test suite is the safety net — if something in the RFCs was missed, we find out the hard way. On the other hand, there's no crate to fight when the MCP spec evolves or when we need to change token semantics.
Who knows if this is right long-term. It works today, it's well-tested, and it was cheap to build. If maintaining it becomes painful, we revisit.