feat(mcp): per-user OAuth for HTTP MCP connections - #3013
Conversation
Static credential injection (Chainlit#2292) covers servers where the user already holds a token. This adds the authorization flow itself, opt-in per connection via `useOAuth` so existing connections are unaffected. Discovery, dynamic client registration and PKCE come from the MCP SDK's OAuthClientProvider. What the SDK cannot supply is the part that only matters on a multi-user server: its TokenStorage takes no arguments, so a single storage shared across requests would hand one user's access token to the next caller. McpOAuthTokenStore keys tokens by (user identifier, server) and hands the SDK a view fixed to one pair. Servers are compared on scheme, host and path, so a token issued for one server mounted on a host is never sent to another mounted beside it. The redirect returns on a route shared by every user, so PendingAuthorizations resolves a callback only for the user who started it; a state belonging to someone else is refused rather than completed against the wrong account. States are single-use, expire, and are URL-safe — random_secret's alphabet contains %, /, = and ?, which do not survive a query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
The pending map was keyed on a state Chainlit generated, but the SDK mints its own, embeds it in the authorization URL and compares the returned value with compare_digest. The browser therefore echoed the SDK's state, resolve() raised KeyError, and no flow could ever complete. Register the owner when the redirect is handed over, keyed by the state already in that URL, and return that same state from the callback handler so the SDK's comparison passes. That also removes the reason for generating a URL-safe state here: the SDK owns state generation now, so random_secret's alphabet is no longer involved. A failed or malformed callback now abandons the caller's own flow, so the waiting connection fails fast instead of hanging until the state expires. Ownership is checked there for the same reason resolve() checks it: otherwise anyone holding a state could cancel someone else's connection. Scope the flow to the HTTP-authenticated caller rather than the session user; the callback route sees the former, so the two must agree or every callback is refused as belonging to someone else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All four addressed in 0fc62eb. The first one was correct and serious — thanks. P1, The owner is now recorded in the This also retires the URL-safe-state change from the first commit: the SDK owns state generation, so P1, P2, P3, VerificationBoth fixes are mutation-tested. Reverting the state fix to a self-generated value reproduces the failure exactly as described: and dropping the 7 new tests (42 total in the file), driving the provider's real |
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/chainlit/server.py">
<violation number="1" location="backend/chainlit/server.py:1310">
P2: When an OAuth connection request is rejected, `connect_mcp` has already popped and closed the existing connection with the same name. Validate OAuth eligibility before replacing the existing MCP session so a failed request cannot disconnect a working connection.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| connection waiting on it fails fast instead of hanging until the state | ||
| expires. | ||
| """ | ||
| if not current_user: |
There was a problem hiding this comment.
P2: When an OAuth connection request is rejected, connect_mcp has already popped and closed the existing connection with the same name. Validate OAuth eligibility before replacing the existing MCP session so a failed request cannot disconnect a working connection.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/chainlit/server.py, line 1310:
<comment>When an OAuth connection request is rejected, `connect_mcp` has already popped and closed the existing connection with the same name. Validate OAuth eligibility before replacing the existing MCP session so a failed request cannot disconnect a working connection.</comment>
<file context>
@@ -1302,18 +1302,26 @@ async def mcp_oauth_callback(
+ connection waiting on it fails fast instead of hanging until the state
+ expires.
"""
+ if not current_user:
+ raise HTTPException(
+ status_code=401, detail="MCP OAuth requires an authenticated user."
</file context>
|
@r0h1tb This might (or might not) be redundant with the recent release. We had to keep it under quarantine due to the security risks, hence I didn't tell you. |
Part of #2197. Opt-in, additive, and safe to land on its own.
Problem
#2292 shipped the static-credential half of #2197: headers on SSE and Streamable HTTP cover any server where the user already holds a long-lived token. The authorization flow — Chainlit obtaining a token on the user's behalf — is still missing.
Discovery, dynamic client registration and PKCE are already solved by the MCP SDK's
OAuthClientProvider, so this does not reimplement them. What the SDK cannot solve is the part that only exists on a multi-user server:get_tokens()takes no arguments. The SDK assumes one storage per authorization context, which holds for the single-user desktop clients it targets. Chainlit serves many users from one process, so a storage shared across requests hands one user's access token to the next caller.Fix
chainlit/mcp_oauth.py:McpOAuthTokenStorekeys tokens by(user identifier, server)and only exposes them throughscoped(user, server), which fixes both halves of the key up front. The SDK receives a view it cannot read outside.canonical_server_keycompares scheme, host and path. Equivalent URLs (case, default port, trailing slash) reuse one token; two servers mounted on one host do not, so a token issued for/jirais never presented to/confluence.PendingAuthorizationscorrelates the redirect back to the user who started it. The callback route is shared, sostateis the only link: a state belonging to another user is refused rather than completed against the wrong account. States are single-use and expire.useOAuthdefaults toFalseon the SSE and Streamable HTTP request models, so existing connections are byte-for-byte unaffected. Stdio is rejected with a 400.Two details worth flagging in review:
stateusessecrets.token_urlsafe, notrandom_secret.random_secret's alphabet contains%,/,=and?, which do not survive a query string — this failed a callback test before it was changed.PendingAuthorizations.start()deliberately does not create the future. The callback can land before anything awaits it, and creating it eagerly also madestart()unusable outside a running loop.The interactive half — surfacing
mcp_authorization_requiredin the UI — is left for a follow-up; the backend emits it today.Tests
35 tests in
backend/tests/test_mcp_oauth.py, covering canonicalisation, the SDK protocol contract (by constructing a realOAuthClientProvider), isolation, expiry/replay, and the callback route.The isolation guarantee is mutation-tested. Keying the store on the server alone — the SDK's own assumption — fails 5 tests, including the leak itself:
Backend suite,
tests/excludingdata/,langchain/,llama_index/(optional deps absent locally):The 8 failures are pre-existing and identical in both runs (
slack_bolt,botbuilder,polarsnot installed).ruff check,ruff format --checkandmypyare clean on the touched files.Summary by cubic
Adds per-user OAuth for HTTP
mcpconnections so Chainlit can obtain and reuse tokens per logged-in user. Previously only static headers were sent; HTTP transports can now opt into OAuth viauseOAuth, default behavior is unchanged, and Stdio remains unsupported.New Features
McpOAuthTokenStorekeyed by (user identifier, canonical server) and exposed via a scopedTokenStorage.PendingAuthorizationskeyed on the SDK-generatedstate; states are single-use, expire, and enforce ownership./mcp/oauth/callback: returns 401 if unauthenticated, 403 if thestatebelongs to another user, and 400 on provider errors or missing/unknown/expiredstate/code; errors abandon the owner’s flow.ConnectSseMCPRequestandConnectStreamableHttpMCPRequestwithuseOAuth: false; when true, builds a per-userOAuthClientProvider, emitsmcp_authorization_requiredwith the authorization URL, and attaches it to HTTP clients; flows are scoped to the HTTP-authenticated caller.Migration
useOAuth: trueon HTTP MCP connections and handling themcp_authorization_requiredevent in the UI.Written for commit 42a933b. Summary will update on new commits.