open-webui / open-webui/open-webui

bug: MCP tool-server OAuth never requests access_type=offline, so Google MCP sessions are deleted every hour

Open
#28,319 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Python
Stars
153k
Forks
22.3k
Avg merge
1d 4h
Merged PRs (30d)
194

Description

Bug Description

MCP tool servers authenticated with oauth_2.1 / oauth_2.1_static cannot obtain a refresh token from Google, because the authorization request built for tool servers never carries access_type=offline. Google therefore issues an online-only access token (expires_in: 3599, no refresh_token), and ~55 minutes later Open WebUI deletes the session. Every user must re-authorize every hour, for every Google MCP server.

This is specifically the tool server OAuth path. The equivalent problem on the login path was already solved — GOOGLE_OAUTH_AUTHORIZE_PARAMS / OAUTH_AUTHORIZE_PARAMS exist and work (#22652, "Addressed in dev") — but neither reaches MCP connections.

Environment

  • Open WebUI: v0.11.0 (Docker, digest-pinned)
  • MCP servers: Google's hosted Workspace MCP servers (https://gmailmcp.googleapis.com/mcp/v1, drivemcp, calendarmcp, …)
  • Auth type: OAuth 2.1 (Static), confidential client, PKCE S256
  • Verified against v0.11.0 and mainbackend/open_webui/utils/oauth.py is byte-identical on both (md5 48c61d157b9095da18f6d49f648a27c2), and also identical to the file inside the running container. access_type, offline and prompt appear nowhere in it.

Steps to Reproduce

  1. Add an MCP (Streamable HTTP) tool server pointing at https://gmailmcp.googleapis.com/mcp/v1, auth type OAuth 2.1 (Static), with a Google Web-application client.
  2. Complete the consent flow from a chat (Integrations → Tools).
  3. Use the tools — works.
  4. Wait an hour, or simply come back the next morning.
  5. The integration is disconnected and the stored OAuth session is gone. Re-consent required.

Evidence

Authorization redirect actually emitted by GET /oauth/clients/mcp:<id>/authorize (values redacted):

https://accounts.google.com/o/oauth2/v2/auth
  ?response_type=code
  &client_id=<redacted>.apps.googleusercontent.com
  &redirect_uri=https://<host>/oauth/clients/mcp:gmail/callback
  &scope=https://www.googleapis.com/auth/gmail.readonly+https://www.googleapis.com/auth/gmail.compose
  &state=<redacted>
  &code_challenge=<redacted>
  &code_challenge_method=S256

No access_type, no prompt.

Resulting stored session (GET /api/v1/users/{id}/oauth/sessions):

{
  "provider": "mcp:gmail",
  "expires_at": 1786260913,
  "token_keys": ["access_token", "expires_at", "expires_in", "issued_at", "scope", "token_type"]
}

No refresh_token. Google's tokeninfo confirms access_type: "online", expires_in: 3599.

Root Cause

backend/open_webui/utils/oauth.py

  1. build_oauth_request_params() (L775–784) is the only source of extra authorization parameters for tool-server OAuth. It emits scope and resource, nothing else:
def build_oauth_request_params(client_info: OAuthClientInformationFull | None) -> dict:
    if not client_info:
        return {}
    params = {}
    if client_info.scope:
        params['scope'] = client_info.scope
    if should_send_oauth_resource(client_info):
        params['resource'] = client_info.resource
    return params

It is consumed by OAuthClientManager.handle_authorize() (L1171–1187) and _preflight_authorization_url() (L939).

  1. Google requires the non-standard access_type=offline to issue a refresh token; nothing in OIDC discovery advertises this, so authlib cannot infer it.

  2. With no refresh token, get_oauth_token() (L1038–1052) reaches its refresh branch 5 minutes before expiry, _refresh_token() bails at if not token_data.get('refresh_token') (L1100), and the caller deletes the session:

refreshed_token = await self._refresh_token(session)
if refreshed_token:
    return refreshed_token
else:
    log.warning('Token refresh failed ... deleting session')
    await OAuthSessions.delete_session_by_id(session.id)

Note this is not the case fixed by #26141 / _normalize_token_expiry. Google does send expires_in, so the expiry is genuine rather than fabricated; the session is correctly considered expired and then destroyed because a refresh token was never requested in the first place.

Why the existing configuration doesn't help

  • OAUTH_AUTHORIZE_PARAMS is applied only in OAuthManager.handle_login() (L1757–1763) — the SSO login flow.
  • GOOGLE_OAUTH_AUTHORIZE_PARAMS is applied only in google_oauth_register() (config.py L2631) — again the login provider.
  • OAuthClientManager.add_client() has no authorize-parameter passthrough, and the tool-server connection schema has no field for it, so there is no per-connection setting either.

The only current workaround is to set the server's auth type to system_oauth so it forwards the login token (utils/tools.py L153–157), which requires widening GOOGLE_OAUTH_SCOPE to include Gmail/Drive/Calendar scopes for every user at login. That conflates SSO identity with per-tool data access and forces an org-wide consent prompt. Not acceptable in a multi-tenant deployment.

What I'd like guidance on

The one-line change that fixes it locally is adding access_type=offline (plus prompt=consent, so users who already granted get a refresh token re-issued) to build_oauth_request_params when the provider is Google. Before opening a PR I'd rather match maintainer preference, since there are several reasonable shapes:

  1. Per-connection field — an "Authorization parameters" JSON box in the tool-server modal, stored on the connection and merged in build_oauth_request_params. Most flexible; also covers other providers' non-standard parameters.
  2. Reuse the existing env vars — let OAUTH_AUTHORIZE_PARAMS apply to tool-server authorization as well as login. Smallest change, but it's global across all MCP servers.
  3. Provider-conditional default — send access_type=offline automatically when the authorization endpoint resolves to accounts.google.com. Zero configuration and fixes it for everyone hitting Google's hosted Workspace MCP servers, at the cost of a provider special-case in generic code.

Happy to submit a PR against dev for whichever you prefer.

Related

  • #22652 — same parameter, login path only; "Addressed in dev"
  • #26141 — session deletion when no refresh token, for providers omitting expires_in; different trigger, same destructive outcome
  • #19809 — MCP sessions lost after 1 hour, closed as working-as-intended on the assumption that providers issue refresh tokens
  • #25977 — Google hosted MCP servers, PRM discovery

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in backend/open_webui/utils/oauth.py with build_oauth_request_params(), then trace its use from OAuthClientManager.handle_authorize() and _preflight_authorization_url(). Compare the MCP path with the existing login authorization-parameter handling in handle_login() and google_oauth_register(). Done means the chosen approach obtains a Google refresh token for MCP sessions and prevents valid sessions from being deleted after access-token expiry.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
authentication, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.