Skip to content

feat(mcp): per-user OAuth for HTTP MCP connections - #3013

Open
r0h1tb wants to merge 3 commits into
Chainlit:mainfrom
r0h1tb:feat/mcp-oauth-per-user-tokens
Open

feat(mcp): per-user OAuth for HTTP MCP connections#3013
r0h1tb wants to merge 3 commits into
Chainlit:mainfrom
r0h1tb:feat/mcp-oauth-per-user-tokens

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 19, 2026

Copy link
Copy Markdown

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:

class TokenStorage(Protocol):
    async def get_tokens(self) -> OAuthToken | None: ...

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:

  • McpOAuthTokenStore keys tokens by (user identifier, server) and only exposes them through scoped(user, server), which fixes both halves of the key up front. The SDK receives a view it cannot read outside.
  • canonical_server_key compares 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 /jira is never presented to /confluence.
  • PendingAuthorizations correlates the redirect back to the user who started it. The callback route is shared, so state is the only link: a state belonging to another user is refused rather than completed against the wrong account. States are single-use and expire.

useOAuth defaults to False on 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:

  • The state uses secrets.token_urlsafe, not random_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 made start() unusable outside a running loop.

The interactive half — surfacing mcp_authorization_required in 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 real OAuthClientProvider), 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:

FAILED TestIsolation::test_a_token_is_not_readable_by_another_user
  - assert OAuthToken(access_token='alice-token', ...) is None

Backend suite, tests/ excluding data/, langchain/, llama_index/ (optional deps absent locally):

passed failed
before 698 8
after 733 8

The 8 failures are pre-existing and identical in both runs (slack_bolt, botbuilder, polars not installed). ruff check, ruff format --check and mypy are clean on the touched files.


Summary by cubic

Adds per-user OAuth for HTTP mcp connections so Chainlit can obtain and reuse tokens per logged-in user. Previously only static headers were sent; HTTP transports can now opt into OAuth via useOAuth, default behavior is unchanged, and Stdio remains unsupported.

  • New Features

    • Memory-backed McpOAuthTokenStore keyed by (user identifier, canonical server) and exposed via a scoped TokenStorage.
    • Canonicalizes server keys by scheme/host/path, normalizes default ports and trailing slashes, and does not share tokens across different paths.
    • Tracks pending flows with PendingAuthorizations keyed on the SDK-generated state; states are single-use, expire, and enforce ownership.
    • Adds GET /mcp/oauth/callback: returns 401 if unauthenticated, 403 if the state belongs to another user, and 400 on provider errors or missing/unknown/expired state/code; errors abandon the owner’s flow.
    • Extends ConnectSseMCPRequest and ConnectStreamableHttpMCPRequest with useOAuth: false; when true, builds a per-user OAuthClientProvider, emits mcp_authorization_required with the authorization URL, and attaches it to HTTP clients; flows are scoped to the HTTP-authenticated caller.
  • Migration

    • No migration required; enable by setting useOAuth: true on HTTP MCP connections and handling the mcp_authorization_required event in the UI.

Written for commit 42a933b. Summary will update on new commits.

Review in cubic

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>
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. backend Pertains to the Python backend. enhancement New feature or request security unit-tests Has unit tests. labels Aug 19, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread backend/chainlit/server.py
Comment thread backend/chainlit/mcp_oauth.py Outdated
Comment thread backend/chainlit/server.py Outdated
Comment thread backend/tests/test_mcp_oauth.py Outdated
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>
@r0h1tb

r0h1tb commented Aug 19, 2026

Copy link
Copy Markdown
Author

All four addressed in 0fc62eb. The first one was correct and serious — thanks.

P1, mcp_oauth.py:197 — the correlation key was inert. Confirmed against the SDK: OAuthClientProvider._perform_authorization does state = secrets.token_urlsafe(32), puts it in auth_params, and then checks secrets.compare_digest(returned_state, state). My Chainlit-generated state never reached the browser, so every callback looked up a state that was never registered and 400'd. The flow could not complete.

The owner is now recorded in the redirect_handler, keyed by the state parsed out of the authorization URL the SDK just built, and callback_handler returns that same state so the SDK's comparison passes. PendingAuthorizations.start() is replaced by register(state, user, server) — minting a state here was the bug, so the ability to do it is gone rather than left as a trap.

This also retires the URL-safe-state change from the first commit: the SDK owns state generation, so random_secret's alphabet is no longer in the path.

P1, server.py:1306 — a failed callback left the connection hanging. Right: the transport sat in pending.wait() until the 300s TTL. Error and malformed callbacks now abandon the flow so the waiting connection fails fast. abandon() checks ownership for the same reason resolve() does — without it, anyone holding a state could cancel another user's connection, which is covered by test_an_error_cannot_abandon_someone_elses_flow.

P2, server.py:1457 — scope to current_user. Taken as suggested. The callback route authenticates on current_user, so scoping the flow to context.session.user could start something the callback then refuses as belonging to someone else.

P3, test_missing_code_is_rejected — the assertion was not isolating. Correct, the 400 could have come from the unknown-state branch. It now registers a real state, asserts detail == "Missing code or state", and additionally asserts the flow was released.

Verification

Both fixes are mutation-tested. Reverting the state fix to a self-generated value reproduces the failure exactly as described:

FAILED TestProviderStateCorrelation::test_the_flow_is_registered_under_the_sdk_state
  - KeyError: 'Unknown or expired OAuth state.'
FAILED TestProviderStateCorrelation::test_the_callback_handler_returns_the_sdk_state
  - KeyError: 'Unknown or expired OAuth state.'

and dropping the abandon calls fails the two callback tests.

7 new tests (42 total in the file), driving the provider's real redirect_handler / callback_handler rather than the store in isolation — which is the gap that let the original defect through. Backend suite 733 → 740 passed, same 8 pre-existing failures (slack_bolt, botbuilder, polars absent locally). ruff check, ruff format --check and mypy clean on the touched files.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@dokterbob

Copy link
Copy Markdown
Collaborator

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Pertains to the Python backend. enhancement New feature or request security size:L This PR changes 100-499 lines, ignoring generated files. unit-tests Has unit tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants