diff --git a/.env.example b/.env.example index 35ea12a88..f561af2ba 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,8 @@ FORWARDED_ALLOW_IPS='*' # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true -ANONYMIZED_TELEMETRY=false \ No newline at end of file +ANONYMIZED_TELEMETRY=false + +# OAuth Configuration +# Set to 'user' to auto-approve OAuth signups, or 'pending' to require admin activation +# DEFAULT_USER_ROLE=user \ No newline at end of file diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index db0de88a0..a2089ae16 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -575,7 +575,7 @@ def _get_default_letta_redirect_uri(): webui_url = os.environ.get("WEBUI_URL", "") if webui_url: return f"{webui_url.rstrip('/')}/oauth/letta/callback" - return "https://chatlettacom-production.up.railway.app/oauth/letta/callback" + return "https://lettachat.up.railway.app/oauth/letta/callback" LETTA_REDIRECT_URI = PersistentConfig( "LETTA_REDIRECT_URI", @@ -831,7 +831,8 @@ def load_oauth_providers(): access_token_url=f"{LETTA_BASE_URL.value}/api/oauth/token", authorize_url=f"{LETTA_BASE_URL.value}/oauth/authorize", api_base_url=f"{LETTA_BASE_URL.value}/v1", - userinfo_endpoint=f"{LETTA_BASE_URL.value}/api/user/self", + # Note: No userinfo_endpoint - Letta doesn't have one yet. + # User info is extracted from id_token or generated synthetically in oauth.py _letta_userinfo() client_kwargs={ "scope": LETTA_OAUTH_SCOPE.value, "code_challenge_method": "S256", # PKCE @@ -842,14 +843,14 @@ def load_oauth_providers(): else {} ), }, - redirect_uri=LETTA_REDIRECT_URI.value or "https://chatlettacom-production.up.railway.app/oauth/letta/callback", + redirect_uri=LETTA_REDIRECT_URI.value or "https://lettachat.up.railway.app/oauth/letta/callback", ) return client OAUTH_PROVIDERS["letta"] = { - "redirect_uri": LETTA_REDIRECT_URI.value or "https://chatlettacom-production.up.railway.app/oauth/letta/callback", + "redirect_uri": LETTA_REDIRECT_URI.value or "https://lettachat.up.railway.app/oauth/letta/callback", "register": letta_oauth_register, - "sub_claim": "id", # Letta returns "id" instead of "sub" + "sub_claim": "sub", # Standard OpenID Connect claim } configured_providers = ["Letta"] # Always include Letta diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 2c4f69fa0..0fbc6cb6a 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1319,17 +1319,26 @@ class OAuthManager: Manually exchange authorization code for Letta OAuth token. Letta returns non-standard token_type: "access_token" instead of "Bearer". """ - from open_webui.config import LETTA_BASE_URL, LETTA_REDIRECT_URI + from open_webui.config import LETTA_BASE_URL code = request.query_params.get("code") if not code: raise ValueError("Missing authorization code") - # Get code_verifier from session (PKCE) - authlib stores it as _{name}_code_verifier_ - code_verifier = request.session.get("_letta_code_verifier_") or request.session.get("code_verifier") + # Get code_verifier from session (PKCE) + # Authlib stores state data as _state_{provider}_{state_value} with nested 'data' key + state = request.query_params.get("state") + state_key = f"_state_letta_{state}" + state_data = request.session.get(state_key, {}) + # code_verifier is in state_data['data']['code_verifier'] + data = state_data.get("data", {}) if isinstance(state_data, dict) else {} + code_verifier = data.get("code_verifier") if isinstance(data, dict) else None + log.info(f"Letta token exchange: code_verifier={'present' if code_verifier else 'None'}") token_url = f"{LETTA_BASE_URL.value}/api/oauth/token" - redirect_uri = LETTA_REDIRECT_URI.value or "https://chatlettacom-production.up.railway.app/oauth/letta/callback" + # Use dynamic redirect_uri based on request to match what was sent in authorize + redirect_uri = str(request.url_for("oauth_login_callback", provider="letta")) + log.info(f"Letta token exchange: using redirect_uri={redirect_uri}") data = { "grant_type": "authorization_code", @@ -1340,11 +1349,14 @@ class OAuthManager: if code_verifier: data["code_verifier"] = code_verifier + log.info(f"Letta token exchange: POST {token_url} with data={data}") + async with aiohttp.ClientSession(trust_env=True) as session: + # Try JSON first (Letta API may expect JSON) async with session.post( token_url, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, + json=data, + headers={"Content-Type": "application/json"}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as resp: if resp.status != 200: @@ -1358,13 +1370,79 @@ class OAuthManager: token["token_type"] = "Bearer" return token + async def _letta_userinfo(self, token: dict) -> dict: + """ + Extract user info from Letta OAuth token. + + Letta OAuth doesn't have a userinfo endpoint yet, so we: + 1. Try to extract user info from id_token if present (JWT) + 2. Fall back to generating a synthetic user from the access token + """ + import base64 + import json + + access_token = token.get("access_token") + token_type = token.get("token_type", "Bearer") + + log.info(f"Letta userinfo: token_type={token_type}, has_access_token={bool(access_token)}") + + if not access_token: + raise ValueError("Missing access_token in token response") + + # Method 1: Check if user info is in id_token (JWT) + id_token = token.get("id_token") + if id_token: + log.info("Letta userinfo: extracting from id_token") + try: + parts = id_token.split(".") + if len(parts) >= 2: + payload = parts[1] + padding = 4 - len(payload) % 4 + if padding != 4: + payload += "=" * padding + decoded = base64.urlsafe_b64decode(payload) + user_data = json.loads(decoded) + log.info(f"Letta id_token claims: {user_data.keys()}") + email = user_data.get("email") + return { + "sub": user_data.get("sub"), + "email": email, + "name": user_data.get("name") or (email.split("@")[0] if email else None), + "picture": user_data.get("picture"), + } + except Exception as e: + log.warning(f"Failed to decode id_token: {e}") + + # Method 2: Since Letta doesn't have a userinfo endpoint yet, + # generate a synthetic user from the access token + # The access token format is at-let-{user_id_prefix}... + # This is a workaround until Letta adds a proper userinfo endpoint + log.info("Letta userinfo: generating synthetic user from access token (no userinfo endpoint)") + # Extract a unique identifier from the access token + # Token format: at-let-{12char_id}... + token_id = access_token[7:19] if access_token.startswith("at-let-") else access_token[:12] + # Generate synthetic email since Open WebUI requires one + synthetic_email = f"user-{token_id}@letta.local" + return { + "sub": f"letta-{token_id}", + "email": synthetic_email, + "name": "Letta User", + "picture": None, + } + async def handle_login(self, request, provider): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) - # If the provider has a custom redirect URL, use that, otherwise automatically generate one - redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for( - "oauth_login_callback", provider=provider - ) + # For Letta, always use dynamic redirect URI based on request origin + # This allows the same code to work for local dev and production + if provider == "letta": + redirect_uri = str(request.url_for("oauth_login_callback", provider=provider)) + log.info(f"Letta OAuth login: using redirect_uri={redirect_uri}") + else: + # For other providers, use custom redirect URL if set, otherwise auto-generate + redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for( + "oauth_login_callback", provider=provider + ) client = self.get_client(provider) if client is None: raise HTTPException(404) @@ -1387,24 +1465,19 @@ class OAuthManager: ): auth_params["client_id"] = client.client_id - try: - token = await client.authorize_access_token(request, **auth_params) - except Exception as e: - # Letta returns non-standard token_type: "access_token" instead of "Bearer" - # Handle this by manually exchanging the token - error_str = str(e).lower() - if provider == "letta" and ( - "unsupported_token_type" in error_str - or "status_code" in error_str # compliance_fix error - or "400" in error_str - ): - log.info(f"Letta OAuth: handling token exchange error ({e}), manually exchanging token") - try: - token = await self._letta_token_exchange(request, client) - except Exception as letta_e: - log.error(f"Letta manual token exchange failed: {letta_e}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - else: + # Letta returns non-standard token_type: "access_token" instead of "Bearer" + # which authlib rejects. Always use manual token exchange for Letta. + if provider == "letta": + log.info("Letta OAuth: using manual token exchange") + try: + token = await self._letta_token_exchange(request, client) + except Exception as letta_e: + log.error(f"Letta manual token exchange failed: {letta_e}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + else: + try: + token = await client.authorize_access_token(request, **auth_params) + except Exception as e: detailed_error = _build_oauth_callback_error_message(e) log.warning( "OAuth callback error during authorize_access_token for provider %s: %s", @@ -1421,7 +1494,11 @@ class OAuthManager: or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) ): - user_data: UserInfo = await client.userinfo(token=token) + # Letta needs manual userinfo fetch since we did manual token exchange + if provider == "letta": + user_data = await self._letta_userinfo(token) + else: + user_data: UserInfo = await client.userinfo(token=token) if ( provider == "feishu" and isinstance(user_data, dict)