open-webui / open-webui/open-webui
bug: MCP tool-server OAuth never requests access_type=offline, so Google MCP sessions are deleted every hour
Nobody has claimed this yet.
- 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.0andmain—backend/open_webui/utils/oauth.pyis byte-identical on both (md5 48c61d157b9095da18f6d49f648a27c2), and also identical to the file inside the running container.access_type,offlineandpromptappear nowhere in it.
Steps to Reproduce
- Add an MCP (Streamable HTTP) tool server pointing at
https://gmailmcp.googleapis.com/mcp/v1, auth typeOAuth 2.1 (Static), with a Google Web-application client. - Complete the consent flow from a chat (Integrations → Tools).
- Use the tools — works.
- Wait an hour, or simply come back the next morning.
- 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
build_oauth_request_params()(L775–784) is the only source of extra authorization parameters for tool-server OAuth. It emitsscopeandresource, 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).
-
Google requires the non-standard
access_type=offlineto issue a refresh token; nothing in OIDC discovery advertises this, so authlib cannot infer it. -
With no refresh token,
get_oauth_token()(L1038–1052) reaches its refresh branch 5 minutes before expiry,_refresh_token()bails atif 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_PARAMSis applied only inOAuthManager.handle_login()(L1757–1763) — the SSO login flow.GOOGLE_OAUTH_AUTHORIZE_PARAMSis applied only ingoogle_oauth_register()(config.pyL2631) — 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:
- 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. - Reuse the existing env vars — let
OAUTH_AUTHORIZE_PARAMSapply to tool-server authorization as well as login. Smallest change, but it's global across all MCP servers. - Provider-conditional default — send
access_type=offlineautomatically when the authorization endpoint resolves toaccounts.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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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