chat-ui
6f711d7c - feat(mcp): OAuth 2.1 support for MCP servers (#2250)

Commit
30 days ago
feat(mcp): OAuth 2.1 support for MCP servers (#2250) * feat(mcp): add OAuth 2.1 support for MCP servers Implements the MCP authorization spec (2025-06-18) so users can connect custom MCP servers that require OAuth, not just static API keys. When a user adds an MCP server URL we probe it; on a 401 with WWW-Authenticate we walk RFC 9728 (protected-resource metadata) and RFC 8414 (AS metadata), do RFC 7591 dynamic client registration when supported, then run an authorization-code flow with PKCE-S256 and the RFC 8707 resource indicator. Tokens are persisted in localStorage alongside the existing custom-server data and travel with chat requests via the existing headers channel. We refresh expiring tokens just-in-time before each chat send, distinguish AS-side rejections (invalid_grant) from transport blips so a flaky network does not log the user out, and surface a "Reconnect" path on hard failure. UI: AuthorizeStep flow inside the MCP modal handles popup vs full-page redirect (mobile / popup-blocked) and a manual client-id form for AS without DCR. ServerCard shows an Authorized pill with live expiry countdown and a Disconnect button that best-effort-revokes via RFC 7009. * mcp oauth hardening (#2450) * fix(mcp): validate the RFC 9207 iss parameter on the OAuth callback * prevent mcp oauth connection deadlocks (#2466) * fix hanging oauth response * clean * fix(mcp): tolerate null optional client metadata from DCR (#2467) * fix(mcp): correct OAuth pill lifetime and stuck "connecting" after reload - publicOAuthState exposes `refreshable`; the server card treats a refreshable connection as authorized past the short-lived access token and drops the misleading "expires in" countdown (kept only for non-refreshable servers). - The localStorage OAuth sanitizer preserves the new `refreshable` field. - healthCheckServer writes the stable status before persisting, so the transient "connecting" is no longer saved and restored as a stuck state on reload. * chore(mcp): address review — cut 'what' comments, condense the rest to one line Resolves the 23 inline comments on PR #2250: remove narration/'what' comments and single-field annotations, and condense the remaining 'why'/spec comments (discovery, canonicalization, popup/redirect helpers, inline-script escaping) to one line each. No behaviour change. * Apply suggestion from @abidlabs Co-authored-by: Abubakar Abid <islamrealm@gmail.com> * fix(mcp): address Copilot review findings for OAuth - exchange: reject redirects on token/refresh/revoke so the code, PKCE verifier, client secret, and refresh token can't be forwarded cross-origin; bound revoke with a 10s timeout so a hung AS can't block disconnect. - start: let a manually-entered OAuth client be corrected on retry instead of being locked to the first (typo'd) value, while still protecting DCR clients. - disconnect: distinguish a confirmed removal (revoked/404) from a transport failure and keep local state on failure so live credentials aren't stranded; surface a retry hint on the card. - add an owner-checked /api/mcp/oauth/state endpoint and reconcile connection state when the manager opens, so a scope challenge recorded mid-chat no longer leaves a server stuck showing 'Authorized' (and silently dropped). - add JIT refresh tests: rotation, refresh-token preservation, invalid_grant clears credentials, transient failure retains them, concurrent single-update. * fix(mcp): recover OAuth popup flows that close before the result lands When the popup's postMessage is dropped (most often a PUBLIC_ORIGIN vs browsing-origin mismatch, where the callback posts to a different origin than the opener accepts), the popup still closes and the flow reported 'window closed before completing' even though the token exchange completed server-side. On popup-closed/timeout, re-check the connection via /api/mcp/oauth/state and, if it is already authorized, treat the flow as successful instead of failing. * fix(mcp): address security review findings for OAuth - urlSafety: strip Authorization/Cookie/Proxy-Authorization when a redirect changes origin (matching native fetch), so an MCP endpoint can't forward the user's bearer to another host across 301/302/303/307/308. (+ redirect test) - clientPool: single-flight cold-miss connects so concurrent requests can't orphan authenticated clients that are never tracked/closed; don't close a client that has an in-flight call on an idle-ping failure. - disconnect: revoke before delete, revoke both tokens, and keep the record (signal 502) when a possible revocation fails so the user can retry instead of stranding a live credential; 'force' still removes it when deleting the whole server. (+ tests) - health check now runs OAuth discovery for an existing/base server that returns 401/403 with no connection, and base-server cards get the reauthorize handler, so pre-existing and env-configured OAuth servers have an Authorize path. * feat(mcp): encrypt OAuth credentials at rest and expire idle connections A1 — encrypt the secret fields of an OAuth connection (access/refresh/id tokens, client secret, PKCE verifier) with AES-256-GCM keyed by MCP_OAUTH_ENCRYPTION_KEY. Metadata (expiry, scope, client_id, CSRF state) stays plaintext so projections and lookups are unchanged. Values are tagged 'enc.v1:'; decryption passes through untagged legacy plaintext, so existing records migrate lazily on rewrite and an unset key keeps today's plaintext behavior (with a startup warning). Encrypt on write, decrypt at point-of-use; the redundant tokens.refresh_token match in the refresh path is dropped in favor of the version guard. B1 — authenticated connections get a 90-day sliding idle TTL (previously none), refreshed on each use via the existing deleteAt TTL index, so abandoned grants don't persist forever. Adds crypto round-trip tests and documents MCP_OAUTH_ENCRYPTION_KEY in .env. * test(mcp): expect the sliding TTL on migrated OAuth connections B1 gives authenticated connections a 90-day idle TTL, so a migrated connection's deleteAt is no longer undefined. (CI-caught assertion the OAuth subset missed.) * port mcp oauth to the v2 mcp sdk main moved to @modelcontextprotocol/client (#2508); the flat v1 error classes are now OAuthError with a code, and the metadata schemas live in @modelcontextprotocol/core. * fix mcp oauth review findings - finish the v2 SDK port: pass iss to the code exchange, skip the SDK's exact-string issuer check in favour of ours, drop the last @modelcontextprotocol/sdk imports - time out token/refresh requests - guard token writes on the grant (grantVersion) and the refreshed token set instead of the whole-document version, so scope challenges and refreshes can't discard exchanged or rotated tokens - revoke a grant that was exchanged but couldn't be stored - restart the pending-connection TTL when a flow starts - report expired non-refreshable tokens and undecryptable secrets as needing authorization - persist OAuth state for base servers - only report a connection missing when it is, not on transient errors - give full-page flows a way back and surface their errors - resolve OAuth tokens in parallel on the chat path - reuse mcpFetchForServer and hasAuthHeader in the health route; drop duplicate connection reads * keep waiting when a COOP login page severs the oauth popup A provider page served with Cross-Origin-Opener-Policy makes the popup read as closed while the user is still signing in, and its result message never arrives. Poll the connection's server-side state instead of failing, with Authorize left enabled for a real close. The callback page keeps the close hint alongside the return link when it has no opener. --------- Co-authored-by: pngwn <hello@pngwn.io> Co-authored-by: Abubakar Abid <islamrealm@gmail.com>
Author
Parents
Loading