Coverage for open_webui/routers/auths.py: 38%
766 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import asyncio
4import datetime
5import logging
6import re
7import time
8import urllib
9import uuid
10from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
12from aiohttp import BasicAuth, ClientSession
13from fastapi import APIRouter, Depends, HTTPException, Request, status
14from fastapi.responses import JSONResponse, Response
15from ldap3 import NONE, Connection, Server, Tls
16from ldap3.utils.conv import escape_filter_chars
17from ldap3.utils.dn import parse_dn
18from open_webui.config import (
19 ENABLE_PASSWORD_AUTH,
20 OAUTH_PROVIDERS,
21)
22from open_webui.constants import ERROR_MESSAGES
23from open_webui.events import EVENTS, publish_event
24from open_webui.env import (
25 AIOHTTP_CLIENT_SESSION_SSL,
26 ENABLE_INITIAL_ADMIN_SIGNUP,
27 ENABLE_OAUTH_TOKEN_EXCHANGE,
28 OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
29 OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
30 OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS,
31 WEBUI_AUTH,
32 WEBUI_AUTH_COOKIE_SAME_SITE,
33 WEBUI_AUTH_COOKIE_SECURE,
34 WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
35 WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
36 WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
37 WEBUI_AUTH_TRUSTED_NAME_HEADER,
38 WEBUI_AUTH_TRUSTED_ROLE_HEADER,
39)
40from open_webui.internal.db import get_async_session
41from open_webui.models.auths import (
42 AddUserForm,
43 ApiKey,
44 Auths,
45 LdapForm,
46 SigninForm,
47 SigninResponse,
48 SignupForm,
49 Token,
50 UpdatePasswordForm,
51)
52from open_webui.models.config import Config
53from open_webui.models.groups import Groups
54from open_webui.models.oauth_sessions import OAuthSessions
55from open_webui.models.users import (
56 UpdateProfileForm,
57 UserModel,
58 UserProfileImageResponse,
59 Users,
60 UserStatus,
61)
62from open_webui.utils.access_control import get_permissions, has_permission
63from open_webui.utils.auth import (
64 create_api_key,
65 create_token,
66 decode_token,
67 get_admin_user,
68 get_current_user,
69 get_http_authorization_cred,
70 get_password_hash,
71 get_verified_user,
72 invalidate_token,
73 revoke_user_tokens,
74 validate_password,
75 verify_password,
76)
77from open_webui.utils.groups import apply_default_group_assignment
78from open_webui.utils.json_codec import JSONCodec
79from open_webui.utils.misc import parse_duration, validate_email_format
80from open_webui.utils.rate_limit import RateLimiter
81from pydantic import BaseModel, StrictStr, field_validator
82from sqlalchemy.exc import IntegrityError
83from sqlalchemy.ext.asyncio import AsyncSession
85router = APIRouter()
87log = logging.getLogger(__name__)
89# Forgive us our failed attempts, as we forgive those
90# who exceed their allotted rate against this gate.
91signin_rate_limiter = RateLimiter(limit=10**9, window=60 * 3)
92# Best-effort throttle only: there is no caller identity before the provider answers,
93# and deployments may derive request.client from proxy headers.
94token_exchange_rate_limiter = (
95 RateLimiter(
96 limit=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT,
97 window=OAUTH_TOKEN_EXCHANGE_RATE_LIMIT_WINDOW,
98 )
99 if OAUTH_TOKEN_EXCHANGE_RATE_LIMIT is not None
100 else None
101)
104ADMIN_CONFIG_KEYS = {
105 'SHOW_ADMIN_DETAILS': 'auth.admin.show',
106 'ADMIN_EMAIL': 'auth.admin.email',
107 'WEBUI_URL': 'webui.url',
108 'ENABLE_LOGIN_FORM': 'ui.enable_login_form',
109 'ENABLE_SIGNUP': 'ui.enable_signup',
110 'ENABLE_API_KEYS': 'auth.enable_api_keys',
111 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': 'auth.api_key.endpoint_restrictions',
112 'API_KEYS_ALLOWED_ENDPOINTS': 'auth.api_key.allowed_endpoints',
113 'DEFAULT_USER_ROLE': 'ui.default_user_role',
114 'DEFAULT_GROUP_ID': 'ui.default_group_id',
115 'DEFAULT_INTERFACE_SETTINGS': 'ui.default_interface_settings',
116 'I18N': 'ui.i18n',
117 'JWT_EXPIRES_IN': 'auth.jwt_expiry',
118 'ENABLE_COMMUNITY_SHARING': 'ui.enable_community_sharing',
119 'ENABLE_MESSAGE_RATING': 'ui.enable_message_rating',
120 'ENABLE_FOLDERS': 'folders.enable',
121 'FOLDER_MAX_FILE_COUNT': 'folders.max_file_count',
122 'AUTOMATION_MAX_COUNT': 'automations.max_count',
123 'AUTOMATION_MIN_INTERVAL': 'automations.min_interval',
124 'ENABLE_AUTOMATIONS': 'automations.enable',
125 'ENABLE_CHANNELS': 'channels.enable',
126 'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode',
127 'ENABLE_CALENDAR': 'calendar.enable',
128 'ENABLE_MEMORIES': 'memories.enable',
129 'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable',
130 'ENABLE_NOTES': 'notes.enable',
131 'ENABLE_USER_WEBHOOKS': 'ui.enable_user_webhooks',
132 'ENABLE_USER_STATUS': 'users.enable_status',
133 'PENDING_USER_OVERLAY_TITLE': 'ui.pending_user_overlay_title',
134 'PENDING_USER_OVERLAY_CONTENT': 'ui.pending_user_overlay_content',
135 'RESPONSE_WATERMARK': 'ui.watermark',
136}
138LDAP_SERVER_CONFIG_KEYS = {
139 'label': 'ldap.server.label',
140 'host': 'ldap.server.host',
141 'port': 'ldap.server.port',
142 'attribute_for_mail': 'ldap.server.attribute_for_mail',
143 'attribute_for_username': 'ldap.server.attribute_for_username',
144 'app_dn': 'ldap.server.app_dn',
145 'app_dn_password': 'ldap.server.app_password',
146 'search_base': 'ldap.server.users_dn',
147 'search_filters': 'ldap.server.search_filter',
148 'use_tls': 'ldap.server.use_tls',
149 'certificate_path': 'ldap.server.ca_cert_file',
150 'validate_cert': 'ldap.server.validate_cert',
151 'ciphers': 'ldap.server.ciphers',
152 'enable_group_management': 'ldap.group.enable_management',
153 'enable_group_creation': 'ldap.group.enable_creation',
154 'attribute_for_groups': 'ldap.server.attribute_for_groups',
155}
158async def get_config_values(key_map: dict[str, str]) -> dict:
159 values = await Config.get_many(*key_map.values())
160 return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
163def config_updates(data: dict, key_map: dict[str, str]) -> dict:
164 return {key_map[field]: value for field, value in data.items() if field in key_map}
167async def create_session_response(
168 request: Request,
169 user,
170 db,
171 response: Response = None,
172 set_cookie: bool = False,
173 source: str = 'api',
174) -> dict:
175 """
176 Create JWT token and build session response for a user.
177 Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
179 Args:
180 request: FastAPI request object
181 user: User object
182 db: Database session
183 response: FastAPI response object (required if set_cookie is True)
184 set_cookie: Whether to set the auth cookie on the response
185 """
186 expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
187 expires_at = None
188 if expires_delta: 188 ↛ 191line 188 didn't jump to line 191 because the condition on line 188 was always true
189 expires_at = int(time.time()) + int(expires_delta.total_seconds())
191 token = create_token(
192 data={'id': user.id},
193 expires_delta=expires_delta,
194 )
196 if set_cookie and response: 196 ↛ 209line 196 didn't jump to line 209 because the condition on line 196 was always true
197 datetime_expires_at = datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None
198 max_age = int(expires_delta.total_seconds()) if expires_delta else None
199 response.set_cookie(
200 key='token',
201 value=token,
202 expires=datetime_expires_at,
203 httponly=True,
204 samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
205 secure=WEBUI_AUTH_COOKIE_SECURE,
206 **({'max_age': max_age} if max_age is not None else {}),
207 )
209 user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
210 await publish_event(
211 request,
212 EVENTS.AUTH_LOGIN,
213 actor=user,
214 subject_id=user.id,
215 subject_type='user',
216 source=source,
217 data={'auth_method': source},
218 )
220 return {
221 'token': token,
222 'token_type': 'Bearer',
223 'expires_at': expires_at,
224 'id': user.id,
225 'email': user.email,
226 'name': user.name,
227 'role': user.role,
228 'profile_image_url': f'/api/v1/users/{user.id}/profile/image',
229 'permissions': user_permissions,
230 }
233############################
234# GetSessionUser
235############################
238class SessionUserResponse(Token, UserProfileImageResponse):
239 expires_at: int | None = None
240 permissions: dict | None = None
243class SessionUserInfoResponse(SessionUserResponse, UserStatus):
244 bio: str | None = None
245 gender: str | None = None
246 date_of_birth: datetime.date | None = None
249@router.get('/', response_model=SessionUserInfoResponse)
250async def get_session_user(
251 request: Request,
252 response: Response,
253 user=Depends(get_current_user),
254 db: AsyncSession = Depends(get_async_session),
255):
256 token = None
257 auth_header = request.headers.get('Authorization')
258 if auth_header: 258 ↛ 262line 258 didn't jump to line 262 because the condition on line 258 was always true
259 auth_token = get_http_authorization_cred(auth_header)
260 if auth_token is not None: 260 ↛ 262line 260 didn't jump to line 262 because the condition on line 260 was always true
261 token = auth_token.credentials
262 if token is None: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 token = request.cookies.get('token')
264 if token is None and getattr(request.state, 'token', None): 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 token = request.state.token.credentials
266 data = decode_token(token) if token else None
268 expires_at = None
270 if data: 270 ↛ 291line 270 didn't jump to line 291 because the condition on line 270 was always true
271 expires_at = data.get('exp')
273 if (expires_at is not None) and int(time.time()) > expires_at: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 raise HTTPException(
275 status_code=status.HTTP_401_UNAUTHORIZED,
276 detail=ERROR_MESSAGES.INVALID_TOKEN,
277 )
279 # Set the cookie token
280 max_age = int(expires_at - time.time()) if expires_at else None
281 response.set_cookie(
282 key='token',
283 value=token,
284 expires=(datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None),
285 httponly=True, # Ensures the cookie is not accessible via JavaScript
286 samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
287 secure=WEBUI_AUTH_COOKIE_SECURE,
288 **({'max_age': max_age} if max_age is not None else {}),
289 )
291 user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
293 response_data = {
294 'token': token,
295 'token_type': 'Bearer',
296 'expires_at': expires_at,
297 'id': user.id,
298 'email': user.email,
299 'name': user.name,
300 'role': user.role,
301 'profile_image_url': user.profile_image_url,
302 'bio': user.bio,
303 'gender': user.gender,
304 'date_of_birth': user.date_of_birth,
305 'status_emoji': user.status_emoji,
306 'status_message': user.status_message,
307 'status_expires_at': user.status_expires_at,
308 'permissions': user_permissions,
309 }
311 return response_data
314############################
315# Update Profile
316############################
319@router.post('/update/profile', response_model=UserProfileImageResponse)
320async def update_profile(
321 request: Request,
322 form_data: UpdateProfileForm,
323 session_user=Depends(get_verified_user),
324 db: AsyncSession = Depends(get_async_session),
325):
326 if session_user: 326 ↛ 344line 326 didn't jump to line 344 because the condition on line 326 was always true
327 user = await Users.update_user_by_id(
328 session_user.id,
329 form_data.model_dump(),
330 db=db,
331 )
332 if user: 332 ↛ 342line 332 didn't jump to line 342 because the condition on line 332 was always true
333 await publish_event(
334 request,
335 EVENTS.USER_PROFILE_UPDATED,
336 actor=session_user,
337 subject_id=session_user.id,
338 data={'updated_fields': list(form_data.model_dump().keys())},
339 )
340 return user
341 else:
342 raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())
343 else:
344 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
347############################
348# Update Timezone
349############################
352class UpdateTimezoneForm(BaseModel):
353 timezone: str
356@router.post('/update/timezone')
357async def update_timezone(
358 request: Request,
359 form_data: UpdateTimezoneForm,
360 session_user=Depends(get_current_user),
361 db: AsyncSession = Depends(get_async_session),
362):
363 if session_user: 363 ↛ 378line 363 didn't jump to line 378 because the condition on line 363 was always true
364 await Users.update_user_by_id(
365 session_user.id,
366 {'timezone': form_data.timezone},
367 db=db,
368 )
369 await publish_event(
370 request,
371 EVENTS.USER_UPDATED,
372 actor=session_user,
373 subject_id=session_user.id,
374 data={'updated_fields': ['timezone']},
375 )
376 return {'status': True}
377 else:
378 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
381############################
382# Update Password
383############################
386@router.post('/update/password', response_model=bool)
387async def update_password(
388 request: Request,
389 form_data: UpdatePasswordForm,
390 session_user=Depends(get_current_user),
391 db: AsyncSession = Depends(get_async_session),
392):
393 # Trusted-header auth mode delegates passwords to the reverse proxy
394 if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
395 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
396 if session_user:
397 user = await Auths.authenticate_user(
398 session_user.email,
399 lambda pw: verify_password(form_data.password, pw),
400 db=db,
401 )
403 if user:
404 try:
405 validate_password(form_data.new_password)
406 except Exception as e:
407 raise HTTPException(400, detail=str(e))
408 hashed = await get_password_hash(form_data.new_password)
409 success = await Auths.update_user_password_by_id(user.id, hashed, db=db)
410 if success:
411 await revoke_user_tokens(request, user.id)
412 await publish_event(
413 request,
414 EVENTS.AUTH_PASSWORD_CHANGED,
415 actor=user,
416 subject_id=user.id,
417 subject_type='user',
418 )
419 return success
420 else:
421 raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
422 else:
423 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
426def _unescape_ldap_dn_value(value: str) -> str:
427 """Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``.
429 Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together.
430 """
431 hexdigits = '0123456789abcdefABCDEF'
432 result = []
433 pos = 0
434 length = len(value)
435 while pos < length:
436 char = value[pos]
437 if char == '\\' and pos + 1 < length:
438 if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits:
439 byte_values = bytearray()
440 while (
441 pos + 2 < length
442 and value[pos] == '\\'
443 and value[pos + 1] in hexdigits
444 and value[pos + 2] in hexdigits
445 ):
446 byte_values.append(int(value[pos + 1 : pos + 3], 16))
447 pos += 3
448 result.append(byte_values.decode('utf-8', errors='replace'))
449 else:
450 # Backslash escaping a literal special char, e.g. "\," or "\+".
451 result.append(value[pos + 1])
452 pos += 2
453 else:
454 result.append(char)
455 pos += 1
456 return ''.join(result)
459def extract_group_cn_from_dn(group_dn: str) -> str | None:
460 """Return the first CN component of an LDAP group DN, or None.
462 Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose
463 name contains a comma) are handled correctly instead of naively splitting
464 on ``,``.
465 """
466 for attr_type, attr_value, _ in parse_dn(group_dn):
467 if attr_type.upper() == 'CN':
468 return _unescape_ldap_dn_value(attr_value)
469 return None
472############################
473# LDAP Authentication
474############################
475@router.post('/ldap', response_model=SessionUserResponse)
476async def ldap_auth(
477 request: Request,
478 response: Response,
479 form_data: LdapForm,
480 db: AsyncSession = Depends(get_async_session),
481):
482 # Security checks FIRST - before loading any config
483 if not await Config.get('ldap.enable'): 483 ↛ 486line 483 didn't jump to line 486 because the condition on line 483 was always true
484 raise HTTPException(400, detail='LDAP authentication is not enabled')
486 if not ENABLE_PASSWORD_AUTH:
487 raise HTTPException(
488 status_code=status.HTTP_403_FORBIDDEN,
489 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
490 )
492 # Reject empty passwords before attempting the LDAP bind.
493 # Per RFC 4513 §5.1.2, a Simple Bind with a non-empty DN but empty
494 # password is "unauthenticated simple authentication" — many LDAP
495 # servers (OpenLDAP default, some AD configs) return success for these,
496 # which would grant access without valid credentials.
497 if not form_data.password or not form_data.password.strip():
498 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
500 # NOW load LDAP config variables
501 LDAP_SERVER_LABEL = await Config.get('ldap.server.label')
502 LDAP_SERVER_HOST = await Config.get('ldap.server.host')
503 LDAP_SERVER_PORT = await Config.get('ldap.server.port')
504 LDAP_ATTRIBUTE_FOR_MAIL = await Config.get('ldap.server.attribute_for_mail')
505 LDAP_ATTRIBUTE_FOR_USERNAME = await Config.get('ldap.server.attribute_for_username')
506 LDAP_SEARCH_BASE = await Config.get('ldap.server.users_dn')
507 LDAP_SEARCH_FILTERS = await Config.get('ldap.server.search_filter')
508 LDAP_APP_DN = await Config.get('ldap.server.app_dn')
509 LDAP_APP_PASSWORD = await Config.get('ldap.server.app_password')
510 LDAP_USE_TLS = await Config.get('ldap.server.use_tls')
511 LDAP_CA_CERT_FILE = await Config.get('ldap.server.ca_cert_file')
512 LDAP_VALIDATE_CERT = CERT_REQUIRED if await Config.get('ldap.server.validate_cert') else CERT_NONE
513 LDAP_CIPHERS = await Config.get('ldap.server.ciphers') if await Config.get('ldap.server.ciphers') else 'ALL'
515 try:
516 tls = Tls(
517 validate=LDAP_VALIDATE_CERT,
518 version=PROTOCOL_TLS,
519 ca_certs_file=LDAP_CA_CERT_FILE,
520 ciphers=LDAP_CIPHERS,
521 )
522 except Exception as e:
523 log.error(f'TLS configuration error: {str(e)}')
524 raise HTTPException(400, detail='Failed to configure TLS for LDAP connection.')
526 try:
527 server = Server(
528 host=LDAP_SERVER_HOST,
529 port=LDAP_SERVER_PORT,
530 get_info=NONE,
531 use_ssl=LDAP_USE_TLS,
532 tls=tls,
533 )
534 connection_app = Connection(
535 server,
536 LDAP_APP_DN,
537 LDAP_APP_PASSWORD,
538 auto_bind='NONE',
539 authentication='SIMPLE' if LDAP_APP_DN else 'ANONYMOUS',
540 )
541 if not await asyncio.to_thread(connection_app.bind):
542 raise HTTPException(400, detail='Application account bind failed')
544 ENABLE_LDAP_GROUP_MANAGEMENT = await Config.get('ldap.group.enable_management')
545 ENABLE_LDAP_GROUP_CREATION = await Config.get('ldap.group.enable_creation')
546 LDAP_ATTRIBUTE_FOR_GROUPS = await Config.get('ldap.server.attribute_for_groups')
548 search_attributes = [
549 f'{LDAP_ATTRIBUTE_FOR_USERNAME}',
550 f'{LDAP_ATTRIBUTE_FOR_MAIL}',
551 'cn',
552 ]
553 if ENABLE_LDAP_GROUP_MANAGEMENT:
554 search_attributes.append(f'{LDAP_ATTRIBUTE_FOR_GROUPS}')
555 log.info('LDAP Group Management enabled. Adding %s to search attributes', LDAP_ATTRIBUTE_FOR_GROUPS)
556 log.info('LDAP search attributes: %s', search_attributes)
558 search_success = await asyncio.to_thread(
559 connection_app.search,
560 search_base=LDAP_SEARCH_BASE,
561 search_filter=f'(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})',
562 attributes=search_attributes,
563 )
564 if not search_success or not connection_app.entries:
565 raise HTTPException(400, detail='User not found in the LDAP server')
567 entry = connection_app.entries[0]
568 entry_username = entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'].value
569 email = entry[f'{LDAP_ATTRIBUTE_FOR_MAIL}'].value # retrieve the Attribute value
571 username_list = [] # list of usernames from LDAP attribute
572 if isinstance(entry_username, list):
573 username_list = [str(name).lower() for name in entry_username]
574 else:
575 username_list = [str(entry_username).lower()]
577 # TODO: support multiple emails if LDAP returns a list
578 if not email:
579 raise HTTPException(400, 'User does not have a valid email address.')
580 elif isinstance(email, str):
581 email = email.lower()
582 elif isinstance(email, list):
583 email = email[0].lower()
584 else:
585 email = str(email).lower()
587 cn = str(entry['cn']) # common name
588 user_dn = entry.entry_dn # user distinguished name
590 user_groups = []
591 if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
592 group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
593 log.info('LDAP raw group DNs for user %s: %s', username_list, group_dns)
595 if group_dns:
596 log.info('LDAP group_dns original: %s', group_dns)
597 log.info('LDAP group_dns type: %s', type(group_dns))
598 log.info('LDAP group_dns length: %s', len(group_dns))
600 if hasattr(group_dns, 'value'):
601 group_dns = group_dns.value
602 log.info('Extracted .value property: %s', group_dns)
603 elif hasattr(group_dns, '__iter__') and not isinstance(group_dns, (str, bytes)):
604 group_dns = list(group_dns)
605 log.info('Converted to list: %s', group_dns)
607 if isinstance(group_dns, list):
608 group_dns = [str(item) for item in group_dns]
609 else:
610 group_dns = [str(group_dns)]
612 log.info('LDAP group_dns after processing - type: %s, length: %s', type(group_dns), len(group_dns))
614 for group_idx, group_dn in enumerate(group_dns):
615 group_dn = str(group_dn)
616 log.info('Processing group DN #%s: %s', group_idx + 1, group_dn)
618 try:
619 group_cn = extract_group_cn_from_dn(group_dn)
621 if group_cn:
622 user_groups.append(group_cn)
623 else:
624 log.warning(f'Could not extract CN from group DN: {group_dn}')
625 except Exception as e:
626 log.warning(f'Failed to extract group name from DN {group_dn}: {e}')
628 log.info('LDAP groups for user %s: %s (total: %s)', username_list, user_groups, len(user_groups))
629 else:
630 log.info('No groups found for user %s', username_list)
631 elif ENABLE_LDAP_GROUP_MANAGEMENT:
632 log.warning(
633 f'LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry'
634 )
636 if username_list and form_data.user.lower() in username_list:
637 connection_user = Connection(
638 server,
639 user_dn,
640 form_data.password,
641 auto_bind='NONE',
642 authentication='SIMPLE',
643 )
644 if not await asyncio.to_thread(connection_user.bind):
645 raise HTTPException(400, 'Authentication failed.')
647 user = await Users.get_user_by_email(email, db=db)
648 if not user:
649 try:
650 # Insert with default role first to avoid TOCTOU race on
651 # first-user registration. Matches signup_handler pattern.
652 user = await Auths.insert_new_auth(
653 email=email,
654 password=str(uuid.uuid4()),
655 name=cn,
656 role=await Config.get('ui.default_user_role'),
657 db=db,
658 )
660 if not user:
661 raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
663 # Atomically check if this is the only user *after* the
664 # insert. Only the single user present should become admin.
665 if await Users.get_num_users(db=db) == 1:
666 await Users.update_user_role_by_id(user.id, 'admin', db=db)
667 user = await Users.get_user_by_id(user.id, db=db)
669 await apply_default_group_assignment(
670 await Config.get('ui.default_group_id'),
671 user.id,
672 db=db,
673 )
675 await publish_event(
676 request,
677 EVENTS.USER_CREATED,
678 actor=user,
679 subject_id=user.id,
680 source='ldap',
681 data={'role': user.role},
682 )
684 except HTTPException:
685 raise
686 except Exception as err:
687 log.error(f'LDAP user creation error: {str(err)}')
688 raise HTTPException(500, detail='Internal error occurred during LDAP user creation.')
690 user = await Auths.authenticate_user_by_email(email, db=db)
692 if user:
693 if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
694 try:
695 if ENABLE_LDAP_GROUP_CREATION:
696 await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
697 await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
698 log.info('Successfully synced groups for user %s: %s', user.id, user_groups)
699 except Exception as e:
700 log.error(f'Failed to sync groups for user {user.id}: {e}')
702 return await create_session_response(request, user, db, response, set_cookie=True, source='ldap')
703 else:
704 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
705 else:
706 raise HTTPException(400, 'User record mismatch.')
707 except Exception as e:
708 log.error(f'LDAP authentication error: {str(e)}')
709 raise HTTPException(400, detail='LDAP authentication failed.')
712############################
713# SignIn
714############################
717@router.post('/signin', response_model=SessionUserResponse)
718async def signin(
719 request: Request,
720 response: Response,
721 form_data: SigninForm,
722 db: AsyncSession = Depends(get_async_session),
723):
724 if not ENABLE_PASSWORD_AUTH: 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 raise HTTPException(
726 status_code=status.HTTP_403_FORBIDDEN,
727 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
728 )
730 auth_source = 'password'
732 if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 auth_source = 'trusted_header'
734 if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
735 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
737 email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
738 name = email
740 if WEBUI_AUTH_TRUSTED_NAME_HEADER:
741 name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
742 try:
743 name = urllib.parse.unquote(name, encoding='utf-8')
744 except Exception as e:
745 pass
747 if not await Users.get_user_by_email(email.lower(), db=db):
748 try:
749 await signup_handler(
750 request,
751 email,
752 str(uuid.uuid4()),
753 name,
754 db=db,
755 source='trusted_header',
756 )
757 except IntegrityError:
758 if not await Users.get_user_by_email(email.lower(), db=db):
759 raise
761 user = await Auths.authenticate_user_by_email(email, db=db)
762 if user:
763 if WEBUI_AUTH_TRUSTED_GROUPS_HEADER:
764 group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',')
765 group_names = [name.strip() for name in group_names if name.strip()]
767 if group_names:
768 await Groups.sync_groups_by_group_names(user.id, group_names, db=db)
770 if WEBUI_AUTH_TRUSTED_ROLE_HEADER:
771 trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip()
772 if trusted_role in {'admin', 'user', 'pending'}:
773 if user.role != trusted_role:
774 updated_user = await Users.update_user_role_by_id(user.id, trusted_role, db=db)
775 if updated_user:
776 user = updated_user
777 await publish_event(
778 request,
779 EVENTS.USER_ROLE_UPDATED,
780 actor=updated_user,
781 subject_id=updated_user.id,
782 source='trusted_header',
783 data={'role': updated_user.role},
784 )
785 elif trusted_role:
786 log.warning(f'Ignoring invalid trusted role header value: {trusted_role}')
788 elif WEBUI_AUTH == False: 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true
789 auth_source = 'system'
790 admin_email = 'admin@localhost'
791 admin_password = 'admin'
793 if await Users.get_user_by_email(admin_email.lower(), db=db):
794 user = await Auths.authenticate_user(
795 admin_email.lower(),
796 lambda pw: verify_password(admin_password, pw),
797 db=db,
798 )
799 else:
800 if await Users.has_users(db=db):
801 raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
803 await signup_handler(
804 request,
805 admin_email,
806 admin_password,
807 'User',
808 db=db,
809 source='system',
810 )
812 user = await Auths.authenticate_user(
813 admin_email.lower(),
814 lambda pw: verify_password(admin_password, pw),
815 db=db,
816 )
817 else:
818 if await signin_rate_limiter.is_limited(request.app.state.redis, form_data.email.lower()): 818 ↛ 819line 818 didn't jump to line 819 because the condition on line 818 was never true
819 raise HTTPException(
820 status_code=status.HTTP_429_TOO_MANY_REQUESTS,
821 detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
822 )
824 user = await Auths.authenticate_user(
825 form_data.email.lower(),
826 lambda pw: verify_password(form_data.password, pw),
827 db=db,
828 )
830 if user:
831 return await create_session_response(request, user, db, response, set_cookie=True, source=auth_source)
832 else:
833 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
836############################
837# SignUp
838############################
841async def signup_handler(
842 request: Request,
843 email: str,
844 password: str,
845 name: str,
846 profile_image_url: str = '/user.png',
847 *,
848 db: AsyncSession,
849 source: str = 'api',
850) -> UserModel:
851 """
852 Core user-creation logic shared by the signup endpoint and
853 trusted-header / no-auth auto-registration flows.
855 Returns the newly created UserModel.
856 Raises HTTPException on failure.
857 """
858 # Insert with default role first to avoid TOCTOU race on first signup.
859 # If has_users() is checked before insert, concurrent requests during
860 # first-user registration can all see an empty table and each get admin.
861 hashed = await get_password_hash(password)
863 user = await Auths.insert_new_auth(
864 email=email.lower(),
865 password=hashed,
866 name=name,
867 profile_image_url=profile_image_url,
868 role=await Config.get('ui.default_user_role'),
869 db=db,
870 )
871 if not user: 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true
872 raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
874 # Atomically check if this is the only user *after* the insert.
875 # Only the single user present at this point should become admin.
876 if await Users.get_num_users(db=db) == 1: 876 ↛ 881line 876 didn't jump to line 881 because the condition on line 876 was always true
877 await Users.update_user_role_by_id(user.id, 'admin', db=db)
878 user = await Users.get_user_by_id(user.id, db=db)
879 await Config.upsert({'ui.enable_signup': False})
881 await apply_default_group_assignment(
882 await Config.get('ui.default_group_id'),
883 user.id,
884 db=db,
885 )
887 await publish_event(
888 request,
889 EVENTS.USER_CREATED,
890 actor=user,
891 subject_id=user.id,
892 source=source,
893 data={'role': user.role},
894 )
896 return user
899@router.post('/signup', response_model=SessionUserResponse)
900async def signup(
901 request: Request,
902 response: Response,
903 form_data: SignupForm,
904 db: AsyncSession = Depends(get_async_session),
905):
906 has_users = await Users.has_users(db=db)
908 if WEBUI_AUTH: 908 ↛ 916line 908 didn't jump to line 916 because the condition on line 908 was always true
909 if has_users:
910 if not await Config.get('ui.enable_signup') or not await Config.get('ui.enable_login_form'): 910 ↛ 919line 910 didn't jump to line 919 because the condition on line 910 was always true
911 raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
912 # Don't gate the first admin on ENABLE_SIGNUP: it auto-disables and can persist stale across a DB reset.
913 elif not await Config.get('ui.enable_login_form') and not ENABLE_INITIAL_ADMIN_SIGNUP: 913 ↛ 914line 913 didn't jump to line 914 because the condition on line 913 was never true
914 raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
915 else:
916 if has_users:
917 raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
919 if not validate_email_format(form_data.email.lower()): 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true
920 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT)
922 if await Users.get_user_by_email(form_data.email.lower(), db=db): 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
925 try:
926 try:
927 validate_password(form_data.password)
928 except Exception as e:
929 raise HTTPException(400, detail=str(e))
931 user = await signup_handler(
932 request,
933 form_data.email,
934 form_data.password,
935 form_data.name,
936 form_data.profile_image_url,
937 db=db,
938 )
939 await publish_event(
940 request,
941 EVENTS.AUTH_SIGNUP,
942 actor=user,
943 subject_id=user.id,
944 subject_type='user',
945 data={'email': user.email},
946 )
947 return await create_session_response(request, user, db, response, set_cookie=True)
948 except HTTPException:
949 raise
950 except Exception as err:
951 log.error(f'Signup error: {str(err)}')
952 raise HTTPException(500, detail='An internal error occurred during signup.')
955@router.post('/signout')
956async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
957 # get auth token from headers or cookies
958 token = None
959 auth_header = request.headers.get('Authorization')
960 if auth_header:
961 auth_cred = get_http_authorization_cred(auth_header)
962 if auth_cred is not None:
963 token = auth_cred.credentials
964 if token is None:
965 token = request.cookies.get('token')
967 oauth_session_id = request.cookies.get('oauth_session_id')
968 session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db) if oauth_session_id else None
970 if token:
971 actor = None
972 data = decode_token(token)
973 if data and data.get('id'):
974 actor = await Users.get_user_by_id(data['id'], db=db)
975 await invalidate_token(request, token)
976 await publish_event(
977 request,
978 EVENTS.AUTH_LOGOUT,
979 actor=actor,
980 subject_id=actor.id if actor else None,
981 subject_type='user' if actor else None,
982 **({'source': 'oauth', 'data': {'auth_method': 'oauth', 'provider': session.provider}} if session else {}),
983 )
985 response.delete_cookie('token')
986 try:
987 request.session.clear()
988 except Exception:
989 pass
990 response.delete_cookie('owui-session')
991 response.delete_cookie('oui-session')
992 response.delete_cookie('oauth_id_token')
994 if oauth_session_id:
995 response.delete_cookie('oauth_session_id')
997 # If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
998 # there directly instead of attempting OIDC discovery.
999 openid_end_session_endpoint = await Config.get('oauth.end_session_endpoint')
1000 if openid_end_session_endpoint:
1001 return JSONResponse(
1002 status_code=200,
1003 content={
1004 'status': True,
1005 'redirect_url': openid_end_session_endpoint,
1006 },
1007 headers=response.headers,
1008 )
1010 openid_provider_url = await Config.get('oauth.provider_url')
1011 oauth_server_metadata_url = (
1012 request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None
1013 ) or openid_provider_url
1015 if session and oauth_server_metadata_url:
1016 oauth_id_token = session.token.get('id_token')
1017 try:
1018 async with ClientSession(trust_env=True) as session:
1019 async with session.get(oauth_server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
1020 if r.status == 200:
1021 openid_data = await r.json()
1022 logout_url = openid_data.get('end_session_endpoint')
1024 if logout_url:
1025 return JSONResponse(
1026 status_code=200,
1027 content={
1028 'status': True,
1029 'redirect_url': f'{logout_url}?id_token_hint={oauth_id_token}'
1030 + (
1031 f'&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}'
1032 if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
1033 else ''
1034 ),
1035 },
1036 headers=response.headers,
1037 )
1038 else:
1039 raise Exception('Failed to fetch OpenID configuration')
1041 except Exception as e:
1042 log.error(f'OpenID signout error: {str(e)}')
1043 raise HTTPException(
1044 status_code=500,
1045 detail='Failed to sign out from the OpenID provider.',
1046 headers=response.headers,
1047 )
1049 if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
1050 return JSONResponse(
1051 status_code=200,
1052 content={
1053 'status': True,
1054 'redirect_url': WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
1055 },
1056 headers=response.headers,
1057 )
1059 return JSONResponse(status_code=200, content={'status': True}, headers=response.headers)
1062############################
1063# OAuth Session Management
1064############################
1067@router.delete('/oauth/sessions/{provider:path}', response_model=bool)
1068async def delete_oauth_session_by_provider(
1069 request: Request,
1070 provider: str,
1071 user=Depends(get_verified_user),
1072 db: AsyncSession = Depends(get_async_session),
1073):
1074 """
1075 Disconnect the current user's OAuth session for a specific provider.
1076 The provider string matches the 'provider' field in the oauth_session table
1077 (e.g. 'mcp:server-id' for MCP connections).
1078 """
1079 result = await OAuthSessions.delete_sessions_by_user_id_and_provider(user.id, provider, db=db)
1080 if not result: 1080 ↛ 1085line 1080 didn't jump to line 1085 because the condition on line 1080 was always true
1081 raise HTTPException(
1082 status_code=status.HTTP_404_NOT_FOUND,
1083 detail='No OAuth session found for this provider',
1084 )
1085 await publish_event(
1086 request,
1087 EVENTS.AUTH_OAUTH_SESSION_DELETED,
1088 actor=user,
1089 subject_id=user.id,
1090 subject_type='user',
1091 data={'provider': provider},
1092 )
1093 return True
1096############################
1097# AddUser
1098############################
1101@router.post('/add', response_model=SigninResponse)
1102async def add_user(
1103 request: Request,
1104 form_data: AddUserForm,
1105 user=Depends(get_admin_user),
1106 db: AsyncSession = Depends(get_async_session),
1107):
1108 admin_user = user
1109 if not validate_email_format(form_data.email.lower()):
1110 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT)
1112 if await Users.get_user_by_email(form_data.email.lower(), db=db): 1112 ↛ 1115line 1112 didn't jump to line 1115 because the condition on line 1112 was always true
1113 raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
1115 try:
1116 try:
1117 validate_password(form_data.password)
1118 except Exception as e:
1119 raise HTTPException(400, detail=str(e))
1121 hashed = await get_password_hash(form_data.password)
1122 user = await Auths.insert_new_auth(
1123 form_data.email.lower(),
1124 hashed,
1125 form_data.name,
1126 form_data.profile_image_url,
1127 form_data.role,
1128 db=db,
1129 )
1131 if user:
1132 await apply_default_group_assignment(
1133 await Config.get('ui.default_group_id'),
1134 user.id,
1135 db=db,
1136 )
1137 await publish_event(
1138 request,
1139 EVENTS.USER_CREATED,
1140 actor=admin_user,
1141 subject_id=user.id,
1142 source='admin',
1143 data={'role': user.role},
1144 )
1146 expires_delta = parse_duration(await Config.get('auth.jwt_expiry'))
1147 token = create_token(data={'id': user.id}, expires_delta=expires_delta)
1148 return {
1149 'token': token,
1150 'token_type': 'Bearer',
1151 'id': user.id,
1152 'email': user.email,
1153 'name': user.name,
1154 'role': user.role,
1155 'profile_image_url': f'/api/v1/users/{user.id}/profile/image',
1156 }
1157 else:
1158 raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
1159 except HTTPException:
1160 raise
1161 except Exception as err:
1162 log.error(f'Add user error: {str(err)}')
1163 raise HTTPException(500, detail='An internal error occurred while adding the user.')
1166############################
1167# GetAdminDetails
1168############################
1171@router.get('/admin/details')
1172async def get_admin_details(
1173 request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
1174):
1175 if await Config.get('auth.admin.show'): 1175 ↛ 1196line 1175 didn't jump to line 1196 because the condition on line 1175 was always true
1176 admin_email = await Config.get('auth.admin.email')
1177 admin_name = None
1179 log.info('Admin details - Email: %s, Name: %s', admin_email, admin_name)
1181 if admin_email: 1181 ↛ 1182line 1181 didn't jump to line 1182 because the condition on line 1181 was never true
1182 admin = await Users.get_user_by_email(admin_email, db=db)
1183 if admin:
1184 admin_name = admin.name
1185 else:
1186 admin = await Users.get_first_user(db=db)
1187 if admin: 1187 ↛ 1191line 1187 didn't jump to line 1191 because the condition on line 1187 was always true
1188 admin_email = admin.email
1189 admin_name = admin.name
1191 return {
1192 'name': admin_name,
1193 'email': admin_email,
1194 }
1195 else:
1196 raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
1199############################
1200# ToggleSignUp
1201############################
1204@router.get('/admin/config')
1205async def get_admin_config(request: Request, user=Depends(get_admin_user)):
1206 return await get_config_values(ADMIN_CONFIG_KEYS)
1209class AdminConfig(BaseModel):
1210 SHOW_ADMIN_DETAILS: bool
1211 ADMIN_EMAIL: str | None = None
1212 WEBUI_URL: str
1213 ENABLE_LOGIN_FORM: bool = True
1214 ENABLE_SIGNUP: bool
1215 ENABLE_API_KEYS: bool
1216 ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
1217 API_KEYS_ALLOWED_ENDPOINTS: str
1218 DEFAULT_USER_ROLE: str
1219 DEFAULT_GROUP_ID: str
1220 DEFAULT_INTERFACE_SETTINGS: dict | None = None
1221 I18N: dict[str, dict[str, StrictStr]] | None = None
1222 JWT_EXPIRES_IN: str
1223 ENABLE_COMMUNITY_SHARING: bool
1224 ENABLE_MESSAGE_RATING: bool
1225 ENABLE_FOLDERS: bool
1226 FOLDER_MAX_FILE_COUNT: int | str | None = None
1227 AUTOMATION_MAX_COUNT: int | str | None = None
1228 AUTOMATION_MIN_INTERVAL: int | str | None = None
1229 ENABLE_AUTOMATIONS: bool
1230 ENABLE_CHANNELS: bool
1231 CHANNEL_MODEL_RESPONSE_MODE: str = 'thread'
1232 ENABLE_CALENDAR: bool
1233 ENABLE_MEMORIES: bool
1234 ENABLE_MEMORY_SYSTEM_CONTEXT: bool
1235 ENABLE_NOTES: bool
1236 ENABLE_USER_WEBHOOKS: bool
1237 ENABLE_USER_STATUS: bool
1238 PENDING_USER_OVERLAY_TITLE: str | None = None
1239 PENDING_USER_OVERLAY_CONTENT: str | None = None
1240 RESPONSE_WATERMARK: str | None = None
1242 @field_validator('I18N')
1243 @classmethod
1244 def validate_i18n(cls, value):
1245 if value is None:
1246 raise ValueError('I18N must be a dictionary')
1247 unsafe_keys = {'__proto__', 'prototype', 'constructor'}
1249 def placeholders(text):
1250 return {match.strip() for match in re.findall(r'\{\{\s*-?\s*([^},]+)(?:,[^}]+)?\s*\}\}', text)}
1252 cleaned = {}
1253 for locale, entries in value.items():
1254 if not locale.strip() or locale in unsafe_keys:
1255 raise ValueError(f'Invalid language: {locale}')
1256 translations = {}
1257 for key, text in entries.items():
1258 if not key.strip() or key in unsafe_keys:
1259 raise ValueError(f'Invalid translation key: {key}')
1260 if text.strip():
1261 if placeholders(key) != placeholders(text):
1262 raise ValueError(f'Interpolation placeholders do not match: {locale}: {key}')
1263 translations[key] = text
1264 if translations:
1265 cleaned[locale] = translations
1266 return cleaned
1269@router.post('/admin/config')
1270async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)):
1271 updates = config_updates(form_data.model_dump(), ADMIN_CONFIG_KEYS)
1272 if 'ENABLE_LOGIN_FORM' not in form_data.model_fields_set:
1273 updates.pop('ui.enable_login_form', None)
1274 if 'I18N' not in form_data.model_fields_set:
1275 updates.pop('ui.i18n', None)
1276 updates['ui.default_interface_settings'] = form_data.DEFAULT_INTERFACE_SETTINGS or {}
1277 updates['folders.max_file_count'] = int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
1278 updates['automations.max_count'] = int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
1279 updates['automations.min_interval'] = (
1280 int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else ''
1281 )
1283 if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']:
1284 updates.pop('ui.default_user_role', None)
1286 if form_data.CHANNEL_MODEL_RESPONSE_MODE not in ['thread', 'channel']:
1287 updates.pop('channels.model_response_mode', None)
1289 pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
1291 # Check if the input string matches the pattern
1292 if not re.match(pattern, form_data.JWT_EXPIRES_IN):
1293 updates.pop('auth.jwt_expiry', None)
1295 await Config.upsert(updates)
1296 return await get_config_values(ADMIN_CONFIG_KEYS)
1299class LdapServerConfig(BaseModel):
1300 label: str
1301 host: str
1302 port: int | None = None
1303 attribute_for_mail: str = 'mail'
1304 attribute_for_username: str = 'uid'
1305 app_dn: str
1306 app_dn_password: str
1307 search_base: str
1308 search_filters: str = ''
1309 use_tls: bool = True
1310 certificate_path: str | None = None
1311 validate_cert: bool = True
1312 ciphers: str | None = 'ALL'
1313 enable_group_management: bool = False
1314 enable_group_creation: bool = False
1315 attribute_for_groups: str = 'memberOf'
1318@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
1319async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
1320 return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
1323@router.post('/admin/config/ldap/server')
1324async def update_ldap_server(request: Request, form_data: LdapServerConfig, user=Depends(get_admin_user)):
1325 required_fields = [
1326 'label',
1327 'host',
1328 'attribute_for_mail',
1329 'attribute_for_username',
1330 'search_base',
1331 ]
1332 for key in required_fields:
1333 value = getattr(form_data, key)
1334 if not value:
1335 raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
1337 # The group attribute is what group management reads from the directory
1338 # entry; an empty value would make group sync silently do nothing.
1339 if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip():
1340 raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups'))
1342 updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS)
1343 updates['ldap.server.app_dn'] = form_data.app_dn or ''
1344 updates['ldap.server.app_password'] = form_data.app_dn_password or ''
1345 await Config.upsert(updates)
1346 return await get_config_values(LDAP_SERVER_CONFIG_KEYS)
1349@router.get('/admin/config/ldap')
1350async def get_ldap_config(request: Request, user=Depends(get_admin_user)):
1351 return {'ENABLE_LDAP': await Config.get('ldap.enable')}
1354class LdapConfigForm(BaseModel):
1355 enable_ldap: bool | None = None
1358@router.post('/admin/config/ldap')
1359async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)):
1360 await Config.upsert({'ldap.enable': form_data.enable_ldap})
1361 return {'ENABLE_LDAP': await Config.get('ldap.enable')}
1364############################
1365# API Key
1366############################
1369class OAuthConfigForm(BaseModel):
1370 """All OAuth/OIDC settings exposed to the admin panel."""
1372 # General OAuth
1373 ENABLE_OAUTH: bool | None = None
1374 ENABLE_OAUTH_SIGNUP: bool | None = None
1375 OAUTH_MERGE_ACCOUNTS_BY_EMAIL: bool | None = None
1376 OAUTH_AUTO_REDIRECT: bool | None = None
1377 OAUTH_ALLOWED_DOMAINS: str | None = None
1378 OAUTH_BLOCKED_GROUPS: str | None = None
1380 # Role management
1381 ENABLE_OAUTH_ROLE_MANAGEMENT: bool | None = None
1382 OAUTH_ROLES_CLAIM: str | None = None
1383 OAUTH_ADMIN_ROLES: str | None = None
1384 OAUTH_ALLOWED_ROLES: str | None = None
1386 # Group management
1387 ENABLE_OAUTH_GROUP_MANAGEMENT: bool | None = None
1388 ENABLE_OAUTH_GROUP_CREATION: bool | None = None
1389 OAUTH_GROUP_CLAIM: str | None = None
1390 OAUTH_GROUP_DEFAULT_SHARE: bool | str | None = None
1392 # OIDC provider settings
1393 OAUTH_PROVIDER_NAME: str | None = None
1394 OPENID_PROVIDER_URL: str | None = None
1395 OAUTH_CLIENT_ID: str | None = None
1396 OAUTH_CLIENT_SECRET: str | None = None
1397 OPENID_REDIRECT_URI: str | None = None
1398 OAUTH_SCOPES: str | None = None
1399 OAUTH_CODE_CHALLENGE_METHOD: str | None = None
1400 OAUTH_TOKEN_ENDPOINT_AUTH_METHOD: str | None = None
1401 OPENID_END_SESSION_ENDPOINT: str | None = None
1402 OAUTH_TIMEOUT: int | str | None = None
1403 OAUTH_CLIENT_TIMEOUT: int | str | None = None
1405 # Claims
1406 OAUTH_EMAIL_CLAIM: str | None = None
1407 OAUTH_USERNAME_CLAIM: str | None = None
1408 OAUTH_PICTURE_CLAIM: str | None = None
1409 OAUTH_SUB_CLAIM: str | None = None
1410 OAUTH_AUDIENCE: str | None = None
1412 # Profile update toggles
1413 OAUTH_UPDATE_EMAIL_ON_LOGIN: bool | None = None
1414 OAUTH_UPDATE_NAME_ON_LOGIN: bool | None = None
1415 OAUTH_UPDATE_PICTURE_ON_LOGIN: bool | None = None
1417 # Token
1418 OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE: bool | None = None
1421OAUTH_COMMA_LIST_FIELDS = {
1422 'OAUTH_ALLOWED_DOMAINS',
1423 'OAUTH_ADMIN_ROLES',
1424 'OAUTH_ALLOWED_ROLES',
1425}
1428OAUTH_CONFIG_KEYS = {
1429 'ENABLE_OAUTH': 'oauth.enable',
1430 'ENABLE_OAUTH_SIGNUP': 'oauth.enable_signup',
1431 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL': 'oauth.merge_accounts_by_email',
1432 'OAUTH_AUTO_REDIRECT': 'oauth.auto_redirect',
1433 'OAUTH_ALLOWED_DOMAINS': 'oauth.allowed_domains',
1434 'OAUTH_BLOCKED_GROUPS': 'oauth.blocked_groups',
1435 'ENABLE_OAUTH_ROLE_MANAGEMENT': 'oauth.enable_role_mapping',
1436 'OAUTH_ROLES_CLAIM': 'oauth.roles_claim',
1437 'OAUTH_ADMIN_ROLES': 'oauth.admin_roles',
1438 'OAUTH_ALLOWED_ROLES': 'oauth.allowed_roles',
1439 'ENABLE_OAUTH_GROUP_MANAGEMENT': 'oauth.enable_group_mapping',
1440 'ENABLE_OAUTH_GROUP_CREATION': 'oauth.enable_group_creation',
1441 'OAUTH_GROUP_CLAIM': 'oauth.group_claim',
1442 'OAUTH_GROUP_DEFAULT_SHARE': 'oauth.group_default_share',
1443 'OAUTH_PROVIDER_NAME': 'oauth.provider_name',
1444 'OPENID_PROVIDER_URL': 'oauth.provider_url',
1445 'OAUTH_CLIENT_ID': 'oauth.client_id',
1446 'OAUTH_CLIENT_SECRET': 'oauth.client_secret',
1447 'OPENID_REDIRECT_URI': 'oauth.redirect_uri',
1448 'OAUTH_SCOPES': 'oauth.scopes',
1449 'OAUTH_CODE_CHALLENGE_METHOD': 'oauth.code_challenge_method',
1450 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD': 'oauth.token_endpoint_auth_method',
1451 'OPENID_END_SESSION_ENDPOINT': 'oauth.end_session_endpoint',
1452 'OAUTH_TIMEOUT': 'oauth.timeout',
1453 'OAUTH_CLIENT_TIMEOUT': 'oauth.client.timeout',
1454 'OAUTH_EMAIL_CLAIM': 'oauth.email_claim',
1455 'OAUTH_USERNAME_CLAIM': 'oauth.username_claim',
1456 'OAUTH_PICTURE_CLAIM': 'oauth.picture_claim',
1457 'OAUTH_SUB_CLAIM': 'oauth.sub_claim',
1458 'OAUTH_AUDIENCE': 'oauth.audience',
1459 'OAUTH_UPDATE_EMAIL_ON_LOGIN': 'oauth.update_email_on_login',
1460 'OAUTH_UPDATE_NAME_ON_LOGIN': 'oauth.update_name_on_login',
1461 'OAUTH_UPDATE_PICTURE_ON_LOGIN': 'oauth.update_picture_on_login',
1462 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE': 'oauth.refresh_token.include_scope',
1463}
1466def _format_oauth_form_value(field: str, value):
1467 if field == 'OAUTH_BLOCKED_GROUPS' and isinstance(value, list): 1467 ↛ 1469line 1467 didn't jump to line 1469 because the condition on line 1467 was never true
1468 # Preserve commas in group names and regex patterns when the form is saved.
1469 return JSONCodec.dumps(value)
1470 if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, list):
1471 return ','.join(str(item) for item in value)
1472 return value
1475def _parse_oauth_update_value(field: str, value):
1476 if field in OAUTH_COMMA_LIST_FIELDS and isinstance(value, str):
1477 return [item.strip() for item in value.split(',') if item.strip()]
1478 if field in {'OAUTH_TIMEOUT', 'OAUTH_CLIENT_TIMEOUT'} and value == '':
1479 return ''
1480 return value
1483async def get_oauth_config_values() -> dict:
1484 values = await Config.get_many(*OAUTH_CONFIG_KEYS.values())
1485 form_values = {
1486 field: _format_oauth_form_value(field, values[storage_key])
1487 for field, storage_key in OAUTH_CONFIG_KEYS.items()
1488 if storage_key in values
1489 }
1490 form_values['ENABLE_OAUTH_PERSISTENT_CONFIG'] = Config.OAUTH_PERSISTENT_ENABLED
1491 return form_values
1494def oauth_config_updates(data: dict) -> dict:
1495 return {
1496 OAUTH_CONFIG_KEYS[field]: _parse_oauth_update_value(field, value)
1497 for field, value in data.items()
1498 if field in OAUTH_CONFIG_KEYS
1499 }
1502class OAuthConfigResponse(OAuthConfigForm):
1503 ENABLE_OAUTH_PERSISTENT_CONFIG: bool
1506@router.get('/admin/config/oauth', response_model=OAuthConfigResponse)
1507async def get_oauth_config(request: Request, user=Depends(get_admin_user)):
1508 return await get_oauth_config_values()
1511@router.post('/admin/config/oauth', response_model=OAuthConfigResponse)
1512async def update_oauth_config(request: Request, form_data: OAuthConfigForm, user=Depends(get_admin_user)):
1513 await Config.upsert(oauth_config_updates(form_data.model_dump(exclude_none=True)))
1514 return await get_oauth_config_values()
1517async def _check_api_key_permission(request: Request, user, db: AsyncSession):
1518 if not await Config.get('auth.enable_api_keys') or ( 1518 ↛ exitline 1518 didn't return from function '_check_api_key_permission' because the condition on line 1518 was always true
1519 user.role != 'admin'
1520 and not await has_permission(user.id, 'features.api_keys', await Config.get('user.permissions'), db=db)
1521 ):
1522 raise HTTPException(
1523 status_code=status.HTTP_403_FORBIDDEN,
1524 detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
1525 )
1528# create api key
1529@router.post('/api_key', response_model=ApiKey)
1530async def generate_api_key(
1531 request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
1532):
1533 await _check_api_key_permission(request, user, db)
1535 api_key = create_api_key()
1536 success = await Users.update_user_api_key_by_id(user.id, api_key, db=db)
1538 if success:
1539 await publish_event(
1540 request,
1541 EVENTS.AUTH_API_KEY_CREATED,
1542 actor=user,
1543 subject_id=user.id,
1544 subject_type='user',
1545 )
1546 return {
1547 'api_key': api_key,
1548 }
1549 else:
1550 raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_API_KEY_ERROR)
1553# delete api key
1554@router.delete('/api_key', response_model=bool)
1555async def delete_api_key(
1556 request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
1557):
1558 await _check_api_key_permission(request, user, db)
1559 success = await Users.delete_user_api_key_by_id(user.id, db=db)
1560 if success:
1561 await publish_event(
1562 request,
1563 EVENTS.AUTH_API_KEY_DELETED,
1564 actor=user,
1565 subject_id=user.id,
1566 subject_type='user',
1567 )
1568 return success
1571# get api key
1572@router.get('/api_key', response_model=ApiKey)
1573async def get_api_key(request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
1574 await _check_api_key_permission(request, user, db)
1575 api_key = await Users.get_user_api_key_by_id(user.id, db=db)
1576 if api_key:
1577 return {
1578 'api_key': api_key,
1579 }
1580 else:
1581 raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
1584############################
1585# Token Exchange
1586############################
1589class TokenExchangeForm(BaseModel):
1590 token: str # OAuth access token from external provider
1593async def get_token_client_id(client, token: str) -> str | None:
1594 """Return the OAuth client_id a token was minted for, when the provider supports introspection."""
1595 try:
1596 metadata = await client.load_server_metadata()
1597 introspection_endpoint = metadata.get('introspection_endpoint')
1598 if not introspection_endpoint:
1599 log.warning('Token exchange trusted-client check requires an introspection_endpoint')
1600 return None
1602 async with ClientSession(trust_env=True) as session:
1603 async with session.post(
1604 introspection_endpoint,
1605 data={'token': token, 'token_type_hint': 'access_token'},
1606 auth=BasicAuth(client.client_id, client.client_secret or ''),
1607 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1608 ) as r:
1609 if r.status != 200:
1610 log.warning(f'Token introspection returned {r.status}')
1611 return None
1612 introspection = await r.json()
1614 if not introspection.get('active'):
1615 log.warning('Token introspection reports the token is inactive')
1616 return None
1618 return introspection.get('client_id')
1619 except Exception as e:
1620 log.warning(f'Token introspection failed: {e}')
1621 return None
1624@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
1625async def token_exchange(
1626 request: Request,
1627 response: Response,
1628 provider: str,
1629 form_data: TokenExchangeForm,
1630 db: AsyncSession = Depends(get_async_session),
1631):
1632 """
1633 Exchange an external OAuth provider token for an OpenWebUI JWT.
1634 This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
1635 """
1636 if not ENABLE_OAUTH_TOKEN_EXCHANGE: 1636 ↛ 1642line 1636 didn't jump to line 1642 because the condition on line 1636 was always true
1637 raise HTTPException(
1638 status_code=status.HTTP_403_FORBIDDEN,
1639 detail='Token exchange is disabled',
1640 )
1642 if token_exchange_rate_limiter and await token_exchange_rate_limiter.is_limited(
1643 request.app.state.redis, request.client.host if request.client else 'unknown'
1644 ):
1645 raise HTTPException(
1646 status_code=status.HTTP_429_TOO_MANY_REQUESTS,
1647 detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
1648 )
1650 provider = provider.lower()
1652 # Check if provider is configured
1653 if provider not in OAUTH_PROVIDERS:
1654 raise HTTPException(
1655 status_code=status.HTTP_404_NOT_FOUND,
1656 detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
1657 )
1658 # Get the OAuth client for this provider
1659 oauth_manager = request.app.state.oauth_manager
1660 client = oauth_manager.get_client(provider)
1661 if not client:
1662 raise HTTPException(
1663 status_code=status.HTTP_404_NOT_FOUND,
1664 detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
1665 )
1667 if OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
1668 token_client_id = await get_token_client_id(client, form_data.token)
1669 if not token_client_id:
1670 raise HTTPException(
1671 status_code=status.HTTP_400_BAD_REQUEST,
1672 detail='Unable to determine which client the token was issued to',
1673 )
1674 if token_client_id not in OAUTH_TOKEN_EXCHANGE_TRUSTED_CLIENT_IDS:
1675 log.warning('Token exchange denied: token was issued to an untrusted client for %s', provider)
1676 raise HTTPException(
1677 status_code=status.HTTP_403_FORBIDDEN,
1678 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1679 )
1681 # Validate the token by calling the userinfo endpoint
1682 try:
1683 token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}
1684 user_data = await client.userinfo(token=token_data)
1686 if not user_data:
1687 raise HTTPException(
1688 status_code=status.HTTP_400_BAD_REQUEST,
1689 detail='Invalid token or unable to fetch user info',
1690 )
1691 except Exception as e:
1692 log.warning(f'Token exchange failed for provider {provider}: {e}')
1693 raise HTTPException(
1694 status_code=status.HTTP_400_BAD_REQUEST,
1695 detail='Invalid token or unable to validate with provider',
1696 )
1698 # Extract user information from the token claims
1699 email_claim = await Config.get('oauth.email_claim', 'email')
1701 # Get sub claim
1702 sub_claim = await Config.get('oauth.sub_claim')
1703 sub = user_data.get(sub_claim or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
1704 if not sub:
1705 log.warning(f'Token exchange failed: sub claim missing from user data')
1706 raise HTTPException(
1707 status_code=status.HTTP_400_BAD_REQUEST,
1708 detail="Token missing required 'sub' claim",
1709 )
1710 sub = str(sub)
1712 email = user_data.get(email_claim, '')
1713 if not email:
1714 log.warning(f'Token exchange failed: email claim missing from user data')
1715 raise HTTPException(
1716 status_code=status.HTTP_400_BAD_REQUEST,
1717 detail='Token missing required email claim',
1718 )
1719 email = email.lower()
1721 # Enforce domain allowlist — same check as the normal OAuth callback
1722 oauth_allowed_domains = await Config.get('oauth.allowed_domains', [])
1723 if isinstance(oauth_allowed_domains, str):
1724 oauth_allowed_domains = [domain.strip() for domain in oauth_allowed_domains.split(',') if domain.strip()]
1725 if '*' not in oauth_allowed_domains and email.split('@')[-1] not in oauth_allowed_domains:
1726 log.warning(f'Token exchange denied: email domain not in allowed domains list')
1727 raise HTTPException(
1728 status_code=status.HTTP_403_FORBIDDEN,
1729 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
1730 )
1732 # Try to find the user by OAuth sub
1733 user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
1735 if not user and await Config.get('oauth.merge_accounts_by_email'):
1736 # Try to find by email if merge is enabled
1737 user = await Users.get_user_by_email(email, db=db)
1738 if user:
1739 # Link the OAuth sub to this user
1740 user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user
1742 if user:
1743 provider_oauth = (user.oauth or {}).get(provider) if isinstance(user.oauth, dict) else None
1744 # Lazy repair for legacy rows that stored numeric provider ids as JSON numbers.
1745 if isinstance(provider_oauth, dict) and provider_oauth.get('sub') != sub:
1746 user = await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) or user
1748 if not user:
1749 raise HTTPException(
1750 status_code=status.HTTP_403_FORBIDDEN,
1751 detail='User not found. Please sign in via the web interface first.',
1752 )
1754 user = await oauth_manager.update_user_role_from_oauth(
1755 request=request,
1756 user=user,
1757 user_data=user_data,
1758 provider=provider,
1759 access_token=form_data.token,
1760 db=db,
1761 )
1762 if await Config.get('oauth.enable_group_mapping'):
1763 await oauth_manager.update_user_groups(
1764 request=request,
1765 user=user,
1766 user_data=user_data,
1767 default_permissions=await Config.get('user.permissions'),
1768 db=db,
1769 )
1771 return await create_session_response(request, user, db, source='oauth')