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

1from __future__ import annotations 

2 

3import asyncio 

4import datetime 

5import logging 

6import re 

7import time 

8import urllib 

9import uuid 

10from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS 

11 

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 

84 

85router = APIRouter() 

86 

87log = logging.getLogger(__name__) 

88 

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) 

102 

103 

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} 

137 

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} 

156 

157 

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} 

161 

162 

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} 

165 

166 

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. 

178 

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()) 

190 

191 token = create_token( 

192 data={'id': user.id}, 

193 expires_delta=expires_delta, 

194 ) 

195 

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 ) 

208 

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 ) 

219 

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 } 

231 

232 

233############################ 

234# GetSessionUser 

235############################ 

236 

237 

238class SessionUserResponse(Token, UserProfileImageResponse): 

239 expires_at: int | None = None 

240 permissions: dict | None = None 

241 

242 

243class SessionUserInfoResponse(SessionUserResponse, UserStatus): 

244 bio: str | None = None 

245 gender: str | None = None 

246 date_of_birth: datetime.date | None = None 

247 

248 

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 

267 

268 expires_at = None 

269 

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') 

272 

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 ) 

278 

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 ) 

290 

291 user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db) 

292 

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 } 

310 

311 return response_data 

312 

313 

314############################ 

315# Update Profile 

316############################ 

317 

318 

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) 

345 

346 

347############################ 

348# Update Timezone 

349############################ 

350 

351 

352class UpdateTimezoneForm(BaseModel): 

353 timezone: str 

354 

355 

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) 

379 

380 

381############################ 

382# Update Password 

383############################ 

384 

385 

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 ) 

402 

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) 

424 

425 

426def _unescape_ldap_dn_value(value: str) -> str: 

427 """Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``. 

428 

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) 

457 

458 

459def extract_group_cn_from_dn(group_dn: str) -> str | None: 

460 """Return the first CN component of an LDAP group DN, or None. 

461 

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 

470 

471 

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') 

485 

486 if not ENABLE_PASSWORD_AUTH: 

487 raise HTTPException( 

488 status_code=status.HTTP_403_FORBIDDEN, 

489 detail=ERROR_MESSAGES.ACTION_PROHIBITED, 

490 ) 

491 

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) 

499 

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' 

514 

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.') 

525 

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') 

543 

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') 

547 

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) 

557 

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') 

566 

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 

570 

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()] 

576 

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() 

586 

587 cn = str(entry['cn']) # common name 

588 user_dn = entry.entry_dn # user distinguished name 

589 

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) 

594 

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)) 

599 

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) 

606 

607 if isinstance(group_dns, list): 

608 group_dns = [str(item) for item in group_dns] 

609 else: 

610 group_dns = [str(group_dns)] 

611 

612 log.info('LDAP group_dns after processing - type: %s, length: %s', type(group_dns), len(group_dns)) 

613 

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) 

617 

618 try: 

619 group_cn = extract_group_cn_from_dn(group_dn) 

620 

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}') 

627 

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 ) 

635 

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.') 

646 

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 ) 

659 

660 if not user: 

661 raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) 

662 

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) 

668 

669 await apply_default_group_assignment( 

670 await Config.get('ui.default_group_id'), 

671 user.id, 

672 db=db, 

673 ) 

674 

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 ) 

683 

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.') 

689 

690 user = await Auths.authenticate_user_by_email(email, db=db) 

691 

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}') 

701 

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.') 

710 

711 

712############################ 

713# SignIn 

714############################ 

715 

716 

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 ) 

729 

730 auth_source = 'password' 

731 

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) 

736 

737 email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() 

738 name = email 

739 

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 

746 

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 

760 

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()] 

766 

767 if group_names: 

768 await Groups.sync_groups_by_group_names(user.id, group_names, db=db) 

769 

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}') 

787 

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' 

792 

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) 

802 

803 await signup_handler( 

804 request, 

805 admin_email, 

806 admin_password, 

807 'User', 

808 db=db, 

809 source='system', 

810 ) 

811 

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 ) 

823 

824 user = await Auths.authenticate_user( 

825 form_data.email.lower(), 

826 lambda pw: verify_password(form_data.password, pw), 

827 db=db, 

828 ) 

829 

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) 

834 

835 

836############################ 

837# SignUp 

838############################ 

839 

840 

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. 

854 

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) 

862 

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) 

873 

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}) 

880 

881 await apply_default_group_assignment( 

882 await Config.get('ui.default_group_id'), 

883 user.id, 

884 db=db, 

885 ) 

886 

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 ) 

895 

896 return user 

897 

898 

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) 

907 

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) 

918 

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) 

921 

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) 

924 

925 try: 

926 try: 

927 validate_password(form_data.password) 

928 except Exception as e: 

929 raise HTTPException(400, detail=str(e)) 

930 

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.') 

953 

954 

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') 

966 

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 

969 

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 ) 

984 

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') 

993 

994 if oauth_session_id: 

995 response.delete_cookie('oauth_session_id') 

996 

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 ) 

1009 

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 

1014 

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') 

1023 

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') 

1040 

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 ) 

1048 

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 ) 

1058 

1059 return JSONResponse(status_code=200, content={'status': True}, headers=response.headers) 

1060 

1061 

1062############################ 

1063# OAuth Session Management 

1064############################ 

1065 

1066 

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 

1094 

1095 

1096############################ 

1097# AddUser 

1098############################ 

1099 

1100 

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) 

1111 

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) 

1114 

1115 try: 

1116 try: 

1117 validate_password(form_data.password) 

1118 except Exception as e: 

1119 raise HTTPException(400, detail=str(e)) 

1120 

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 ) 

1130 

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 ) 

1145 

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.') 

1164 

1165 

1166############################ 

1167# GetAdminDetails 

1168############################ 

1169 

1170 

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 

1178 

1179 log.info('Admin details - Email: %s, Name: %s', admin_email, admin_name) 

1180 

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 

1190 

1191 return { 

1192 'name': admin_name, 

1193 'email': admin_email, 

1194 } 

1195 else: 

1196 raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) 

1197 

1198 

1199############################ 

1200# ToggleSignUp 

1201############################ 

1202 

1203 

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) 

1207 

1208 

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 

1241 

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'} 

1248 

1249 def placeholders(text): 

1250 return {match.strip() for match in re.findall(r'\{\{\s*-?\s*([^},]+)(?:,[^}]+)?\s*\}\}', text)} 

1251 

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 

1267 

1268 

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 ) 

1282 

1283 if form_data.DEFAULT_USER_ROLE not in ['pending', 'user', 'admin']: 

1284 updates.pop('ui.default_user_role', None) 

1285 

1286 if form_data.CHANNEL_MODEL_RESPONSE_MODE not in ['thread', 'channel']: 

1287 updates.pop('channels.model_response_mode', None) 

1288 

1289 pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$' 

1290 

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) 

1294 

1295 await Config.upsert(updates) 

1296 return await get_config_values(ADMIN_CONFIG_KEYS) 

1297 

1298 

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' 

1316 

1317 

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) 

1321 

1322 

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)) 

1336 

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')) 

1341 

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) 

1347 

1348 

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')} 

1352 

1353 

1354class LdapConfigForm(BaseModel): 

1355 enable_ldap: bool | None = None 

1356 

1357 

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')} 

1362 

1363 

1364############################ 

1365# API Key 

1366############################ 

1367 

1368 

1369class OAuthConfigForm(BaseModel): 

1370 """All OAuth/OIDC settings exposed to the admin panel.""" 

1371 

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 

1379 

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 

1385 

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 

1391 

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 

1404 

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 

1411 

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 

1416 

1417 # Token 

1418 OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE: bool | None = None 

1419 

1420 

1421OAUTH_COMMA_LIST_FIELDS = { 

1422 'OAUTH_ALLOWED_DOMAINS', 

1423 'OAUTH_ADMIN_ROLES', 

1424 'OAUTH_ALLOWED_ROLES', 

1425} 

1426 

1427 

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} 

1464 

1465 

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 

1473 

1474 

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 

1481 

1482 

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 

1492 

1493 

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 } 

1500 

1501 

1502class OAuthConfigResponse(OAuthConfigForm): 

1503 ENABLE_OAUTH_PERSISTENT_CONFIG: bool 

1504 

1505 

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() 

1509 

1510 

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() 

1515 

1516 

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 ) 

1526 

1527 

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) 

1534 

1535 api_key = create_api_key() 

1536 success = await Users.update_user_api_key_by_id(user.id, api_key, db=db) 

1537 

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) 

1551 

1552 

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 

1569 

1570 

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) 

1582 

1583 

1584############################ 

1585# Token Exchange 

1586############################ 

1587 

1588 

1589class TokenExchangeForm(BaseModel): 

1590 token: str # OAuth access token from external provider 

1591 

1592 

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 

1601 

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() 

1613 

1614 if not introspection.get('active'): 

1615 log.warning('Token introspection reports the token is inactive') 

1616 return None 

1617 

1618 return introspection.get('client_id') 

1619 except Exception as e: 

1620 log.warning(f'Token introspection failed: {e}') 

1621 return None 

1622 

1623 

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 ) 

1641 

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 ) 

1649 

1650 provider = provider.lower() 

1651 

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 ) 

1666 

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 ) 

1680 

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) 

1685 

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 ) 

1697 

1698 # Extract user information from the token claims 

1699 email_claim = await Config.get('oauth.email_claim', 'email') 

1700 

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) 

1711 

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() 

1720 

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 ) 

1731 

1732 # Try to find the user by OAuth sub 

1733 user = await Users.get_user_by_oauth_sub(provider, sub, db=db) 

1734 

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 

1741 

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 

1747 

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 ) 

1753 

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 ) 

1770 

1771 return await create_session_response(request, user, db, source='oauth')