From 655420fd25ed0ea872954baa485030079c00c10e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 23 Jan 2026 04:25:39 +0400 Subject: [PATCH] feat: ENABLE_OAUTH_TOKEN_EXCHANGE --- backend/open_webui/env.py | 6 ++ backend/open_webui/routers/auths.py | 142 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index aa296da5b..42e872fca 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -523,6 +523,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get( "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY ) +# Token Exchange Configuration +# Allows external apps to exchange OAuth tokens for OpenWebUI tokens +ENABLE_OAUTH_TOKEN_EXCHANGE = ( + os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true" +) + #################################### # SCIM Configuration #################################### diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index b1e5bdc8a..254314306 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -37,6 +37,8 @@ from open_webui.env import ( WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, ENABLE_INITIAL_ADMIN_SIGNUP, + ENABLE_OAUTH_TOKEN_EXCHANGE, + AIOHTTP_CLIENT_SESSION_SSL, ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response, JSONResponse @@ -45,6 +47,8 @@ from open_webui.config import ( ENABLE_OAUTH_SIGNUP, ENABLE_LDAP, ENABLE_PASSWORD_AUTH, + OAUTH_PROVIDERS, + OAUTH_MERGE_ACCOUNTS_BY_EMAIL, ) from pydantic import BaseModel @@ -87,6 +91,39 @@ signin_rate_limiter = RateLimiter( redis_client=get_redis_client(), limit=5 * 3, window=60 * 3 ) + +def create_session_response(request: Request, user, db) -> dict: + """ + Create JWT token and build session response for a user. + Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints. + """ + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + + token = create_token( + data={"id": user.id}, + expires_delta=expires_delta, + ) + + user_permissions = get_permissions( + user.id, request.app.state.config.USER_PERMISSIONS, db=db + ) + + return { + "token": token, + "token_type": "Bearer", + "expires_at": expires_at, + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "profile_image_url": user.profile_image_url, + "permissions": user_permissions, + } + + ############################ # GetSessionUser ############################ @@ -1287,3 +1324,108 @@ async def get_api_key( } else: raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) + + +############################ +# Token Exchange +############################ + + +class TokenExchangeForm(BaseModel): + token: str # OAuth access token from external provider + + +@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse) +async def token_exchange( + request: Request, + response: Response, + provider: str, + form_data: TokenExchangeForm, + db: Session = Depends(get_session), +): + """ + Exchange an external OAuth provider token for an OpenWebUI JWT. + This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable. + """ + if not ENABLE_OAUTH_TOKEN_EXCHANGE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Token exchange is disabled", + ) + + provider = provider.lower() + + # Check if provider is configured + if provider not in OAUTH_PROVIDERS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider}' is not configured", + ) + # Get the OAuth client for this provider + oauth_manager = request.app.state.oauth_manager + client = oauth_manager.get_client(provider) + if not client: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"OAuth client for '{provider}' not found", + ) + + # Validate the token by calling the userinfo endpoint + try: + token_data = {"access_token": form_data.token, "token_type": "Bearer"} + user_data = await client.userinfo(token=token_data) + + if not user_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token or unable to fetch user info", + ) + except Exception as e: + log.warning(f"Token exchange failed for provider {provider}: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token or unable to validate with provider", + ) + + # Extract user information from the token claims + email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM + username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM + + # Get sub claim + sub = user_data.get( + request.app.state.config.OAUTH_SUB_CLAIM + or OAUTH_PROVIDERS[provider].get("sub_claim", "sub") + ) + if not sub: + log.warning(f"Token exchange failed: sub claim missing from user data") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token missing required 'sub' claim", + ) + + email = user_data.get(email_claim, "") + if not email: + log.warning(f"Token exchange failed: email claim missing from user data") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token missing required email claim", + ) + email = email.lower() + + # Try to find the user by OAuth sub + user = Users.get_user_by_oauth_sub(provider, sub, db=db) + + if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value: + # Try to find by email if merge is enabled + user = Users.get_user_by_email(email, db=db) + if user: + # Link the OAuth sub to this user + Users.update_user_oauth_by_id(user.id, provider, sub, db=db) + + if not user: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User not found. Please sign in via the web interface first.", + ) + + return create_session_response(request, user, db)