Compare commits

...

24 commits

Author SHA1 Message Date
Brice Ruth
013ec08bac
Merge 5041ad01f5 into ae47101dc6 2025-12-10 17:18:53 +01:00
Timothy Jaeryang Baek
ae47101dc6 refac 2025-12-10 11:07:41 -05:00
Luke Garceau
5041ad01f5 Merge branch 'dev' of github.com:open-webui/open-webui into feat/google-oauth-groups-dev 2025-12-09 16:43:31 -05:00
Luke Garceau
4b46f7d802
Merge branch 'dev' into feat/google-oauth-groups-dev 2025-11-28 14:51:27 -05:00
Luke Garceau
b1800aa224
Merge branch 'main' into feat/google-oauth-groups-dev 2025-11-27 19:02:44 -05:00
Luke Garceau
41e724cdaf resolve incorrect tabulation 2025-11-27 19:00:26 -05:00
Luke Garceau
9358dc2848
Merge branch 'open-webui:main' into main 2025-11-27 18:57:05 -05:00
Luke Garceau
159ef78f6f Merge remote-tracking branch 'origin/dev' into feat/google-oauth-groups-dev
# Conflicts:
#	backend/open_webui/utils/oauth.py
2025-11-27 17:18:02 -05:00
Luke Garceau
89a5dbda45 Merge branch 'main' into feat/google-oauth-groups-dev
# Conflicts:
#	backend/open_webui/utils/oauth.py
#	uv.lock
2025-11-27 16:53:12 -05:00
Luke Garceau
9eb4484e56
Merge pull request #2 from lgarceau768/main
Version 0.6.34 Updates
2025-11-27 15:46:49 -05:00
Tim Baek
140605e660
Merge pull request #19462 from open-webui/dev
Some checks failed
Release to PyPI / release (push) Has been cancelled
Release / release (push) Has been cancelled
Deploy to HuggingFace Spaces / check-secret (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Deploy to HuggingFace Spaces / deploy (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
0.6.40
2025-11-25 06:01:33 -05:00
Tim Baek
9899293f05
Merge pull request #19448 from open-webui/dev
0.6.39
2025-11-25 05:31:34 -05:00
Tim Baek
e3faec62c5
Merge pull request #19416 from open-webui/dev
Some checks are pending
Release / release (push) Waiting to run
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Release to PyPI / release (push) Waiting to run
0.6.38
2025-11-24 07:00:31 -05:00
Tim Baek
fc05e0a6c5
Merge pull request #19405 from open-webui/dev
Some checks are pending
Release / release (push) Waiting to run
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Release to PyPI / release (push) Waiting to run
chore: format
2025-11-23 22:16:33 -05:00
Tim Baek
fe6783c166
Merge pull request #19030 from open-webui/dev
0.6.37
2025-11-23 22:10:05 -05:00
Luke Garceau
d2776965dc
Merge branch 'main' into main 2025-11-22 11:24:58 -05:00
Luke Garceau
6c86ff7d2e
Merge pull request #1 from lgarceau768/feat/google-groups
Google Groups Functionaliity on top of version 0.6.34
2025-11-05 12:05:23 -05:00
Luke Garceau
6dbc01c31b - resolve merge conflicts 2025-11-05 10:44:30 -05:00
Brice Ruth
04811dd15d
update tests for adjusted query string & payload 2025-06-17 09:13:33 -05:00
Brice Ruth
30f4950c5c
fix google cloud identity query string 2025-06-17 09:13:32 -05:00
Brice Ruth
8d6cf357aa
feat: Add Google Cloud Identity API support for OAuth group-based roles
Enables Google Workspace group-based role assignment by integrating with
Google Cloud Identity API to fetch user groups in real-time.

Key improvements:
- Fetches groups directly from Google API using cloud-identity.groups.readonly scope
- Enables admin role assignment based on Google group membership
- Maintains full backward compatibility with existing OAuth configurations
- Includes comprehensive test suite with proper async mocking
- Complete documentation with Google Cloud Console setup guide

Addresses limitation where Google Workspace doesn't include group membership
claims in OAuth JWT tokens, preventing group-based role assignment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 09:13:31 -05:00
Brice Ruth
cc6a1a7d9f
update tests for adjusted query string & payload 2025-06-16 18:18:52 -05:00
Brice Ruth
64ce040388
fix google cloud identity query string 2025-06-16 17:49:42 -05:00
Brice Ruth
a909fd9296
feat: Add Google Cloud Identity API support for OAuth group-based roles
Enables Google Workspace group-based role assignment by integrating with
Google Cloud Identity API to fetch user groups in real-time.

Key improvements:
- Fetches groups directly from Google API using cloud-identity.groups.readonly scope
- Enables admin role assignment based on Google group membership
- Maintains full backward compatibility with existing OAuth configurations
- Includes comprehensive test suite with proper async mocking
- Complete documentation with Google Cloud Console setup guide

Addresses limitation where Google Workspace doesn't include group membership
claims in OAuth JWT tokens, preventing group-based role assignment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 11:35:47 -05:00
4 changed files with 596 additions and 64 deletions

View file

@ -0,0 +1,266 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import aiohttp
from open_webui.utils.oauth import OAuthManager
from open_webui.config import AppConfig
class TestOAuthGoogleGroups:
"""Basic tests for Google OAuth Groups functionality"""
def setup_method(self):
"""Setup test fixtures"""
self.oauth_manager = OAuthManager(app=MagicMock())
@pytest.mark.asyncio
async def test_fetch_google_groups_success(self):
"""Test successful Google groups fetching with proper aiohttp mocking"""
# Mock response data from Google Cloud Identity API
mock_response_data = {
"memberships": [
{
"groupKey": {"id": "admin@company.com"},
"group": "groups/123",
"displayName": "Admin Group"
},
{
"groupKey": {"id": "users@company.com"},
"group": "groups/456",
"displayName": "Users Group"
}
]
}
# Create properly structured async mocks
mock_response = MagicMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=mock_response_data)
# Mock the async context manager for session.get()
mock_get_context = MagicMock()
mock_get_context.__aenter__ = AsyncMock(return_value=mock_response)
mock_get_context.__aexit__ = AsyncMock(return_value=None)
# Mock the session
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_get_context)
# Mock the async context manager for ClientSession
mock_session_context = MagicMock()
mock_session_context.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_context.__aexit__ = AsyncMock(return_value=None)
with patch("aiohttp.ClientSession", return_value=mock_session_context):
groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity(
access_token="test_token",
user_email="user@company.com"
)
# Verify the results
assert groups == ["admin@company.com", "users@company.com"]
# Verify the HTTP call was made correctly
mock_session.get.assert_called_once()
call_args = mock_session.get.call_args
# Check the URL contains the user email (URL encoded)
url_arg = call_args[0][0] # First positional argument
assert "user%40company.com" in url_arg # @ is encoded as %40
assert "searchTransitiveGroups" in url_arg
# Check headers contain the bearer token
headers_arg = call_args[1]["headers"] # headers keyword argument
assert headers_arg["Authorization"] == "Bearer test_token"
assert headers_arg["Content-Type"] == "application/json"
@pytest.mark.asyncio
async def test_fetch_google_groups_api_error(self):
"""Test handling of API errors when fetching groups"""
# Mock failed response
mock_response = MagicMock()
mock_response.status = 403
mock_response.text = AsyncMock(return_value="Permission denied")
# Mock the async context manager for session.get()
mock_get_context = MagicMock()
mock_get_context.__aenter__ = AsyncMock(return_value=mock_response)
mock_get_context.__aexit__ = AsyncMock(return_value=None)
# Mock the session
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_get_context)
# Mock the async context manager for ClientSession
mock_session_context = MagicMock()
mock_session_context.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_context.__aexit__ = AsyncMock(return_value=None)
with patch("aiohttp.ClientSession", return_value=mock_session_context):
groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity(
access_token="test_token",
user_email="user@company.com"
)
# Should return empty list on error
assert groups == []
@pytest.mark.asyncio
async def test_fetch_google_groups_network_error(self):
"""Test handling of network errors when fetching groups"""
# Mock the session that raises an exception when get() is called
mock_session = MagicMock()
mock_session.get.side_effect = aiohttp.ClientError("Network error")
# Mock the async context manager for ClientSession
mock_session_context = MagicMock()
mock_session_context.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_context.__aexit__ = AsyncMock(return_value=None)
with patch("aiohttp.ClientSession", return_value=mock_session_context):
groups = await self.oauth_manager._fetch_google_groups_via_cloud_identity(
access_token="test_token",
user_email="user@company.com"
)
# Should return empty list on network error
assert groups == []
@pytest.mark.asyncio
async def test_get_user_role_with_google_groups(self):
"""Test role assignment using Google groups"""
# Mock configuration
mock_config = MagicMock()
mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True
mock_config.OAUTH_ROLES_CLAIM = "groups"
mock_config.OAUTH_ALLOWED_ROLES = ["users@company.com"]
mock_config.OAUTH_ADMIN_ROLES = ["admin@company.com"]
mock_config.DEFAULT_USER_ROLE = "pending"
mock_config.OAUTH_EMAIL_CLAIM = "email"
user_data = {"email": "user@company.com"}
# Mock Google OAuth scope check and Users class
with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \
patch("open_webui.utils.oauth.GOOGLE_OAUTH_SCOPE") as mock_scope, \
patch("open_webui.utils.oauth.Users") as mock_users, \
patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch:
mock_scope.value = "openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly"
mock_fetch.return_value = ["admin@company.com", "users@company.com"]
mock_users.get_num_users.return_value = 5 # Not first user
role = await self.oauth_manager.get_user_role(
user=None,
user_data=user_data,
provider="google",
access_token="test_token"
)
# Should assign admin role since user is in admin group
assert role == "admin"
mock_fetch.assert_called_once_with("test_token", "user@company.com")
@pytest.mark.asyncio
async def test_get_user_role_fallback_to_claims(self):
"""Test fallback to traditional claims when Google groups fail"""
mock_config = MagicMock()
mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True
mock_config.OAUTH_ROLES_CLAIM = "groups"
mock_config.OAUTH_ALLOWED_ROLES = ["users"]
mock_config.OAUTH_ADMIN_ROLES = ["admin"]
mock_config.DEFAULT_USER_ROLE = "pending"
mock_config.OAUTH_EMAIL_CLAIM = "email"
user_data = {
"email": "user@company.com",
"groups": ["users"]
}
with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \
patch("open_webui.utils.oauth.GOOGLE_OAUTH_SCOPE") as mock_scope, \
patch("open_webui.utils.oauth.Users") as mock_users, \
patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch:
# Mock scope without Cloud Identity
mock_scope.value = "openid email profile"
mock_users.get_num_users.return_value = 5 # Not first user
role = await self.oauth_manager.get_user_role(
user=None,
user_data=user_data,
provider="google",
access_token="test_token"
)
# Should use traditional claims since Cloud Identity scope not present
assert role == "user"
mock_fetch.assert_not_called()
@pytest.mark.asyncio
async def test_get_user_role_non_google_provider(self):
"""Test that non-Google providers use traditional claims"""
mock_config = MagicMock()
mock_config.ENABLE_OAUTH_ROLE_MANAGEMENT = True
mock_config.OAUTH_ROLES_CLAIM = "roles"
mock_config.OAUTH_ALLOWED_ROLES = ["user"]
mock_config.OAUTH_ADMIN_ROLES = ["admin"]
mock_config.DEFAULT_USER_ROLE = "pending"
user_data = {"roles": ["user"]}
with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \
patch("open_webui.utils.oauth.Users") as mock_users, \
patch.object(self.oauth_manager, "_fetch_google_groups_via_cloud_identity") as mock_fetch:
mock_users.get_num_users.return_value = 5 # Not first user
role = await self.oauth_manager.get_user_role(
user=None,
user_data=user_data,
provider="microsoft",
access_token="test_token"
)
# Should use traditional claims for non-Google providers
assert role == "user"
mock_fetch.assert_not_called()
@pytest.mark.asyncio
async def test_update_user_groups_with_google_groups(self):
"""Test group management using Google groups from user_data"""
mock_config = MagicMock()
mock_config.OAUTH_GROUPS_CLAIM = "groups"
mock_config.OAUTH_BLOCKED_GROUPS = "[]"
mock_config.ENABLE_OAUTH_GROUP_CREATION = False
# Mock user with Google groups data
mock_user = MagicMock()
mock_user.id = "user123"
user_data = {
"google_groups": ["developers@company.com", "employees@company.com"]
}
# Mock existing groups and user groups
mock_existing_group = MagicMock()
mock_existing_group.name = "developers@company.com"
mock_existing_group.id = "group1"
mock_existing_group.user_ids = []
mock_existing_group.permissions = {"read": True}
mock_existing_group.description = "Developers group"
with patch("open_webui.utils.oauth.auth_manager_config", mock_config), \
patch("open_webui.utils.oauth.Groups") as mock_groups:
mock_groups.get_groups_by_member_id.return_value = []
mock_groups.get_groups.return_value = [mock_existing_group]
await self.oauth_manager.update_user_groups(
user=mock_user,
user_data=user_data,
default_permissions={"read": True}
)
# Should use Google groups instead of traditional claims
mock_groups.get_groups_by_member_id.assert_called_once_with("user123")
mock_groups.update_group_by_id.assert_called()

View file

@ -7,6 +7,7 @@ import sys
import urllib
import uuid
import json
from urllib.parse import quote
from datetime import datetime, timedelta
import re
@ -15,6 +16,7 @@ import time
import secrets
from cryptography.fernet import Fernet
from typing import Literal
from urllib.parse import quote
import aiohttp
from authlib.integrations.starlette_client import OAuth
@ -58,6 +60,7 @@ from open_webui.config import (
OAUTH_AUDIENCE,
WEBHOOK_URL,
JWT_EXPIRES_IN,
GOOGLE_OAUTH_SCOPE,
AppConfig,
)
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
@ -1001,7 +1004,7 @@ class OAuthManager:
log.error(f"Exception during token refresh for provider {provider}: {e}")
return None
def get_user_role(self, user, user_data):
async def get_user_role(self, user, user_data, provider=None, access_token=None):
user_count = Users.get_num_users()
if user and user_count == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
@ -1021,12 +1024,45 @@ class OAuthManager:
# Default/fallback role if no matching roles are found
role = auth_manager_config.DEFAULT_USER_ROLE
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
# Check if this is Google OAuth with Cloud Identity scope
if (
provider == "google"
and access_token
and "https://www.googleapis.com/auth/cloud-identity.groups.readonly"
in GOOGLE_OAUTH_SCOPE.value
):
log.debug(
"Google OAuth with Cloud Identity scope detected - fetching groups via API"
)
user_email = user_data.get(auth_manager_config.OAUTH_EMAIL_CLAIM, "")
if user_email:
try:
google_groups = (
await self._fetch_google_groups_via_cloud_identity(
access_token, user_email
)
)
# Store groups in user_data for potential group management later
if "google_groups" not in user_data:
user_data["google_groups"] = google_groups
# Use Google groups as oauth_roles for role determination
oauth_roles = google_groups
log.debug(f"Using Google groups as roles: {oauth_roles}")
except Exception as e:
log.error(f"Failed to fetch Google groups: {e}")
# Fall back to default behavior with claims
oauth_roles = []
# If not using Google groups or Google groups fetch failed, use traditional claims method
if not oauth_roles:
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
# Try flat claim structure as alternative
if not claim_data:
@ -1045,6 +1081,47 @@ class OAuthManager:
elif isinstance(claim_data, int):
oauth_roles = [str(claim_data)]
# Check if this is Google OAuth with Cloud Identity scope
if (
provider == "google"
and access_token
and "https://www.googleapis.com/auth/cloud-identity.groups.readonly"
in GOOGLE_OAUTH_SCOPE.value
):
log.debug(
"Google OAuth with Cloud Identity scope detected - fetching groups via API"
)
user_email = user_data.get(auth_manager_config.OAUTH_EMAIL_CLAIM, "")
if user_email:
try:
google_groups = (
await self._fetch_google_groups_via_cloud_identity(
access_token, user_email
)
)
# Store groups in user_data for potential group management later
if "google_groups" not in user_data:
user_data["google_groups"] = google_groups
# Use Google groups as oauth_roles for role determination
oauth_roles = google_groups
log.debug(f"Using Google groups as roles: {oauth_roles}")
except Exception as e:
log.error(f"Failed to fetch Google groups: {e}")
# Fall back to default behavior with claims
oauth_roles = []
# If not using Google groups or Google groups fetch failed, use traditional claims method
if not oauth_roles:
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
oauth_roles = claim_data if isinstance(claim_data, list) else []
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
log.debug(f"Accepted user roles: {oauth_allowed_roles}")
@ -1062,7 +1139,9 @@ class OAuthManager:
for admin_role in oauth_admin_roles:
# If the user has any of the admin roles, assign the role "admin"
if admin_role in oauth_roles:
log.debug("Assigned user the admin role")
log.debug(
f"Assigned user the admin role based on group: {admin_role}"
)
role = "admin"
break
else:
@ -1075,7 +1154,88 @@ class OAuthManager:
return role
def update_user_groups(self, user, user_data, default_permissions):
async def _fetch_google_groups_via_cloud_identity(
self, access_token: str, user_email: str
) -> list[str]:
"""
Fetch Google Workspace groups for a user via Cloud Identity API.
Args:
access_token: OAuth access token with cloud-identity.groups.readonly scope
user_email: User's email address
Returns:
List of group email addresses the user belongs to
"""
groups = []
base_url = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups"
# Create the query string with proper URL encoding
query_string = f"member_key_id == '{user_email}' && 'cloudidentity.googleapis.com/groups.security' in labels"
encoded_query = quote(query_string)
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
page_token = ""
try:
async with aiohttp.ClientSession(trust_env=True) as session:
while True:
# Build URL with query parameter
url = f"{base_url}?query={encoded_query}"
# Add page token to URL if present
if page_token:
url += f"&pageToken={quote(page_token)}"
log.debug("Fetching Google groups via Cloud Identity API")
async with session.get(
url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status == 200:
data = await resp.json()
# Extract group emails from memberships
memberships = data.get("memberships", [])
log.debug(f"Found {len(memberships)} memberships")
for membership in memberships:
group_key = membership.get("groupKey", {})
group_email = group_key.get("id", "")
if group_email:
groups.append(group_email)
log.debug(f"Found group membership: {group_email}")
# Check for next page
page_token = data.get("nextPageToken", "")
if not page_token:
break
else:
error_text = await resp.text()
log.error(
f"Failed to fetch Google groups (status {resp.status})"
)
# Log error details without sensitive information
try:
error_json = json.loads(error_text)
if "error" in error_json:
log.error(f"API error: {error_json['error'].get('message', 'Unknown error')}")
except json.JSONDecodeError:
log.error("Error response contains non-JSON data")
break
except Exception as e:
log.error(f"Error fetching Google groups via Cloud Identity API: {e}")
log.info(f"Retrieved {len(groups)} Google groups for user {user_email}")
return groups
async def update_user_groups(
self, user, user_data, default_permissions, provider=None, access_token=None
):
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
@ -1086,23 +1246,31 @@ class OAuthManager:
blocked_groups = []
user_oauth_groups = []
# Nested claim search for groups claim
if oauth_claim:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
if isinstance(claim_data, list):
user_oauth_groups = claim_data
elif isinstance(claim_data, str):
# Split by the configured separator if present
if OAUTH_GROUPS_SEPARATOR in claim_data:
user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR)
# Check if Google groups were fetched via Cloud Identity API
if "google_groups" in user_data:
log.debug(
"Using Google groups from Cloud Identity API for group management"
)
user_oauth_groups = user_data["google_groups"]
else:
# Nested claim search for groups claim (traditional method)
if oauth_claim:
claim_data = user_data
nested_claims = oauth_claim.split(".")
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
if isinstance(claim_data, list):
user_oauth_groups = claim_data
elif isinstance(claim_data, str):
# Split by the configured separator if present
if OAUTH_GROUPS_SEPARATOR in claim_data:
user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR)
else:
user_oauth_groups = [claim_data]
else:
user_oauth_groups = [claim_data]
else:
user_oauth_groups = []
user_oauth_groups = []
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_all_groups()
@ -1272,7 +1440,7 @@ class OAuthManager:
client = self.get_client(provider)
if client is None:
raise HTTPException(404)
kwargs = {}
if (auth_manager_config.OAUTH_AUDIENCE):
kwargs["audience"] = auth_manager_config.OAUTH_AUDIENCE
@ -1307,9 +1475,8 @@ class OAuthManager:
exc_info=True,
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Try to get userinfo from the token first, some providers include it there
user_data: UserInfo = token.get("userinfo")
if (
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
@ -1395,8 +1562,7 @@ class OAuthManager:
# If allowed domains are configured, check if the email domain is in the list
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1]
not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
@ -1404,7 +1570,8 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider, sub)
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
@ -1415,7 +1582,9 @@ class OAuthManager:
Users.update_user_oauth_by_id(user.id, provider, sub)
if user:
determined_role = self.get_user_role(user, user_data)
determined_role = await self.get_user_role(
user, user_data, provider, token.get("access_token")
)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update the user object in memory as well,
@ -1426,8 +1595,7 @@ class OAuthManager:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
@ -1437,7 +1605,7 @@ class OAuthManager:
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
else:
if not user:
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
@ -1448,14 +1616,14 @@ class OAuthManager:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
else:
picture_url = "/user.png"
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
@ -1463,34 +1631,37 @@ class OAuthManager:
log.warning("Username claim is missing, using email as name")
name = email
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth=oauth_data,
)
role = await self.get_user_role(
None, user_data, provider, token.get("access_token")
)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=role,
oauth_sub=provider_sub,
)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
)
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
@ -1501,14 +1672,14 @@ class OAuthManager:
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if (
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
and user.role != "admin"
):
self.update_user_groups(
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
await self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
provider=provider,
access_token=token.get("access_token"),
)
except Exception as e:

View file

@ -0,0 +1,95 @@
# Google OAuth with Cloud Identity Groups Support
This example demonstrates how to configure Open WebUI to use Google OAuth with Cloud Identity API for group-based role management.
## Configuration
### Environment Variables
```bash
# Google OAuth Configuration
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
# IMPORTANT: Include the Cloud Identity Groups scope
GOOGLE_OAUTH_SCOPE="openid email profile https://www.googleapis.com/auth/cloud-identity.groups.readonly"
# Enable OAuth features
ENABLE_OAUTH_SIGNUP=true
ENABLE_OAUTH_ROLE_MANAGEMENT=true
ENABLE_OAUTH_GROUP_MANAGEMENT=true
# Configure admin roles using Google group emails
OAUTH_ADMIN_ROLES="admin@yourcompany.com,superadmin@yourcompany.com"
OAUTH_ALLOWED_ROLES="users@yourcompany.com,employees@yourcompany.com"
# Optional: Configure group creation
ENABLE_OAUTH_GROUP_CREATION=true
```
## How It Works
1. **Scope Detection**: When a user logs in with Google OAuth, the system checks if the `https://www.googleapis.com/auth/cloud-identity.groups.readonly` scope is present in `GOOGLE_OAUTH_SCOPE`.
2. **Groups Fetching**: If the scope is present, the system uses the Google Cloud Identity API to fetch all groups the user belongs to, instead of relying on claims in the OAuth token.
3. **Role Assignment**:
- If the user belongs to any group listed in `OAUTH_ADMIN_ROLES`, they get admin privileges
- If the user belongs to any group listed in `OAUTH_ALLOWED_ROLES`, they get user privileges
- Default role is applied if no matching groups are found
4. **Group Management**: If `ENABLE_OAUTH_GROUP_MANAGEMENT` is enabled, Open WebUI groups are synchronized with Google Workspace groups.
## Google Cloud Console Setup
1. **Enable APIs**:
- Cloud Identity API
- Cloud Identity Groups API
2. **OAuth 2.0 Setup**:
- Create OAuth 2.0 credentials
- Add authorized redirect URIs
- Configure consent screen
3. **Required Scopes**:
```
openid
email
profile
https://www.googleapis.com/auth/cloud-identity.groups.readonly
```
## Example Groups Structure
```
Your Google Workspace:
├── admin@yourcompany.com (Admin group)
├── superadmin@yourcompany.com (Super admin group)
├── users@yourcompany.com (Regular users)
├── employees@yourcompany.com (All employees)
└── developers@yourcompany.com (Development team)
```
## Fallback Behavior
If the Cloud Identity scope is not present or the API call fails, the system falls back to the traditional method of reading roles from OAuth token claims.
## Security Considerations
- The Cloud Identity API requires proper authentication and authorization
- Only users with appropriate permissions can access group membership information
- Groups are fetched server-side, not exposed to the client
- Access tokens are handled securely and not logged
## Troubleshooting
1. **Groups not detected**: Ensure the Cloud Identity API is enabled and the OAuth client has the required scope
2. **Permission denied**: Verify the service account or OAuth client has Cloud Identity API access
3. **No admin role**: Check that the user belongs to a group listed in `OAUTH_ADMIN_ROLES`
## Benefits Over Token Claims
- **Real-time**: Groups are fetched fresh on each login
- **Complete**: Gets all group memberships, including nested groups
- **Accurate**: No dependency on ID token size limits
- **Flexible**: Can handle complex group hierarchies in Google Workspace

View file

@ -339,7 +339,7 @@
</tr>
</thead>
<tbody class="">
{#each users as user, userIdx}
{#each users as user, userIdx (user.id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button