fix: only accept token exchange tokens minted for trusted OAuth clients

The OAuth token exchange endpoint accepts an access token in the request body and validates it by calling the provider's userinfo endpoint. That endpoint confirms a token is valid but never reveals which OAuth client it was issued to, so a token minted for any client of the same provider was accepted. Anyone able to obtain an access token for a user, for instance by having them sign in to an unrelated OAuth application, could exchange it for an Open WebUI session as that user. The account's role was carried over unchanged, so a targeted admin yielded an admin session.

`OAUTH_ALLOWED_DOMAINS` does not cover this. It filters which user identities may sign in, and an impersonated user is a legitimate member of an allowed domain, so the check passes. The missing question is not who the user is but which client vouched for them.

Resolve the issuing client through RFC 7662 introspection and require it to appear in the new `OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES` allowlist. Only the introspection response's `client_id` is trusted, never `aud`: `client_id` identifies the client the token was issued to, while `aud` names intended resource servers and several identity providers let any client place another client's id there.

The allowlist is a deploy-time environment variable rather than a database setting, so the endpoint's only authentication boundary cannot be widened at runtime from an admin session. It has no default, and an unset allowlist rejects every exchange. That is a breaking change for existing deployments, which must now name the clients they trust, but the previous behaviour trusted every client of the provider and the endpoint is off by default.

This requires a provider that implements RFC 7662. Google, Microsoft and GitHub do not, so token exchange no longer works against them and the rejection is logged. Supporting those providers needs an audience-bound assertion the caller can supply, such as an OIDC ID token validated against the provider's JWKS, which can be added without changing the current request contract.
This commit is contained in:
Classic298 2026-07-26 21:46:12 +02:00
parent d1aa812d80
commit 20c8aaed76
2 changed files with 65 additions and 1 deletions

View file

@ -798,6 +798,15 @@ OAUTH_MAX_SESSIONS_PER_USER = int(os.getenv('OAUTH_MAX_SESSIONS_PER_USER', '10')
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = os.getenv('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true'
# OAuth client IDs whose tokens the exchange endpoint will accept. A provider's
# userinfo endpoint confirms a token is valid, never which client it was minted
# for, so without this allowlist any client of the same provider is trusted.
OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES = [
audience.strip()
for audience in os.getenv('OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES', '').split(',')
if audience.strip()
]
# Back-Channel Logout Configuration
# When enabled, exposes POST /oauth/backchannel-logout for IdP-initiated logout
# per OpenID Connect Back-Channel Logout 1.0 spec.

View file

@ -9,7 +9,7 @@ import urllib
import uuid
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
from aiohttp import ClientSession
from aiohttp import BasicAuth, ClientSession
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from ldap3 import NONE, Connection, Server, Tls
@ -25,6 +25,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES,
WEBUI_AUTH,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
@ -1500,6 +1501,39 @@ class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
async def get_token_client_id(client, token: str) -> str | None:
"""The OAuth client a token was issued to, via RFC 7662 introspection. None if undeterminable."""
try:
metadata = await client.load_server_metadata()
introspection_endpoint = metadata.get('introspection_endpoint')
if not introspection_endpoint:
log.error('Token exchange needs RFC 7662 introspection, which this provider does not advertise')
return None
async with ClientSession(trust_env=True) as session:
async with session.post(
introspection_endpoint,
data={'token': token, 'token_type_hint': 'access_token'},
auth=BasicAuth(client.client_id, client.client_secret or ''),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
log.warning(f'Token introspection returned {r.status}')
return None
introspection = await r.json()
if not introspection.get('active'):
log.warning('Token introspection reports the token is inactive')
return None
# Only client_id names the issuing client. `aud` names intended resource
# servers and some IdPs let any client put another client's id there.
return introspection.get('client_id')
except Exception as e:
log.warning(f'Token introspection failed: {e}')
return None
@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
async def token_exchange(
request: Request,
@ -1535,6 +1569,27 @@ async def token_exchange(
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
)
# Only accept tokens minted for a trusted client; userinfo cannot tell us this.
if not OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES:
log.error('Token exchange rejected: OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES is not set')
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Token exchange has no trusted audiences configured',
)
token_client_id = await get_token_client_id(client, form_data.token)
if not token_client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to determine which client the token was issued to',
)
if token_client_id not in OAUTH_TOKEN_EXCHANGE_ALLOWED_AUDIENCES:
log.warning(f'Token exchange denied: token was issued to an untrusted client for {provider}')
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}