diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 6a942cf9b2..a4ab1e113a 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -206,7 +206,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): import asyncio import json - from open_webui.utils.auth import decode_token + from open_webui.utils.auth import decode_token, is_token_revoked # First-message authentication try: @@ -220,6 +220,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): if data is None or 'id' not in data: await ws.close(code=4001, reason='Invalid token') return None + if await is_token_revoked(ws.app.state.redis, data): + await ws.close(code=4001, reason='Invalid token') + return None user = await Users.get_user_by_id(data['id']) if user is None: await ws.close(code=4001, reason='User not found') diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 2884847a0e..0aa6b8201a 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -38,9 +38,10 @@ from open_webui.models.users import UserNameResponse, Users from open_webui.socket.utils import RedisDict, RedisLock, YdocManager from open_webui.tasks import create_task, stop_item_tasks from open_webui.utils.access_control import has_permission -from open_webui.utils.auth import decode_token +from open_webui.utils.auth import decode_token, is_token_revoked from open_webui.utils.redis import ( build_sentinel_url, + get_redis_client, get_redis_connection, get_sentinels_from_env, ) @@ -338,11 +339,22 @@ async def usage(sid, data): } +# Realtime auth must honour Redis token revocation (sign-out / OIDC back-channel), not just signature/expiry. +AUTH_REVOCATION_REDIS = get_redis_client(async_mode=True) + + +async def _decode_valid_token(token): + data = decode_token(token) + if data is not None and await is_token_revoked(AUTH_REVOCATION_REDIS, data): + return None + return data + + @sio.event async def connect(sid, environ, auth): user = None if auth and 'token' in auth: - data = decode_token(auth['token']) + data = await _decode_valid_token(auth['token']) if data is not None and 'id' in data: user = await Users.get_user_by_id(data['id']) @@ -369,7 +381,7 @@ async def user_join(sid, data): if not auth or 'token' not in auth: return - token_data = decode_token(auth['token']) + token_data = await _decode_valid_token(auth['token']) if token_data is None or 'id' not in token_data: return @@ -438,7 +450,7 @@ async def join_note(sid, data): if not auth or 'token' not in auth: return - token_data = decode_token(auth['token']) + token_data = await _decode_valid_token(auth['token']) if token_data is None or 'id' not in token_data: return diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 26cea6b45f..b93a4a7639 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -213,36 +213,41 @@ def decode_token(token: str) -> dict | None: return None -async def is_valid_token(request, decoded) -> bool: +async def is_token_revoked(redis, decoded) -> bool: """ Check whether a JWT has been revoked. Two mechanisms: 1. Per-token (jti) — used by user-initiated sign-out (known jti). 2. Per-user (revoked_at) — used by OIDC back-channel logout when individual jti values are unknown; rejects tokens with iat <= revoked_at. """ - if request.app.state.redis: - # Per-token revocation - jti = decoded.get('jti') - if jti: - revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked') - if revoked: - return False + if not redis: + return False - # Per-user revocation (OIDC back-channel logout) - user_id = decoded.get('id') - if user_id: - revoked_at = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') - if revoked_at: - try: - revoked_at_ts = int(revoked_at) - token_iat = decoded.get('iat') - # No iat means legacy token — reject since we can't verify issue time - if token_iat is None or token_iat <= revoked_at_ts: - return False - except (ValueError, TypeError): - pass + # Per-token revocation + jti = decoded.get('jti') + if jti: + if await redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked'): + return True - return True + # Per-user revocation (OIDC back-channel logout) + user_id = decoded.get('id') + if user_id: + revoked_at = await redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') + if revoked_at: + try: + revoked_at_ts = int(revoked_at) + token_iat = decoded.get('iat') + # No iat means legacy token — reject since we can't verify issue time + if token_iat is None or token_iat <= revoked_at_ts: + return True + except (ValueError, TypeError): + pass + + return False + + +async def is_valid_token(request, decoded) -> bool: + return not await is_token_revoked(request.app.state.redis, decoded) async def invalidate_token(request, token):