[0.9.28] - 2025-09-28
All checks were successful
Deploy on push / deploy (push) Successful in 2m46s

### 🍪 CRITICAL Cross-Origin Auth
- **🔧 SESSION_COOKIE_DOMAIN**: Добавлена поддержка поддоменов `.discours.io` для cross-origin cookies
- **🌐 Cross-Origin SSE**: Исправлена работа Server-Sent Events с httpOnly cookies между поддоменами
- **🔐 Unified Auth**: Унифицированы настройки cookies для OAuth, login, refresh, logout операций
- **📝 MyPy Compliance**: Исправлена типизация `SESSION_COOKIE_SAMESITE` с использованием `cast()`

### 🛠️ Technical Changes
- **settings.py**: Добавлен `SESSION_COOKIE_DOMAIN` с типобезопасной настройкой SameSite
- **auth/oauth.py**: Обновлены все `set_cookie` вызовы с `domain` параметром
- **auth/middleware.py**: Добавлена поддержка `SESSION_COOKIE_DOMAIN` в logout операциях
- **resolvers/auth.py**: Унифицированы cookie настройки в login/refresh/logout resolvers
- **auth/__init__.py**: Обновлены cookie операции с domain поддержкой

### 📚 Documentation
- **docs/auth/sse-httponly-integration.md**: Новая документация по SSE + httpOnly cookies интеграции
- **docs/auth/architecture.md**: Обновлены диаграммы для unified httpOnly cookie архитектуры

### 🎯 Impact
-  **GraphQL API** (`v3.discours.io`) теперь работает с httpOnly cookies cross-origin
-  **SSE сервер** (`connect.discours.io`) работает с теми же cookies
-  **Безопасность**: httpOnly cookies защищают от XSS атак
-  **UX**: Автоматическая аутентификация без управления токенами в JavaScript
This commit is contained in:
2025-09-28 13:06:03 +03:00
parent fb98a1c6c8
commit 752e2dcbdc
9 changed files with 255 additions and 223 deletions

View File

@@ -16,12 +16,6 @@ from orm.community import Community, CommunityAuthor, CommunityFollower
from settings import (
FRONTEND_URL,
OAUTH_CLIENTS,
SESSION_COOKIE_DOMAIN,
SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SAMESITE,
SESSION_COOKIE_SECURE,
)
from storage.db import local_session
from storage.redis import redis
@@ -487,65 +481,34 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
if not isinstance(redirect_uri, str) or not redirect_uri:
redirect_uri = FRONTEND_URL
# 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект
from urllib.parse import urlparse
# 🎯 Стандартный OAuth flow: токен в URL для фронтенда
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
parsed_redirect = urlparse(redirect_uri)
parsed_url = urlparse(redirect_uri)
# Определяем финальный URL для редиректа
if "testing.discours.io" in parsed_redirect.netloc:
# 💋 Для testing.discours.io используем httpOnly cookie + токен в URL для фронтенда
from urllib.parse import quote
final_redirect_url = (
f"https://testing.discours.io/oauth?access_token={session_token}&redirect_url={quote(redirect_uri)}"
# 🌐 OAuth: токен в URL (стандартный подход)
logger.info("🌐 OAuth: using token in URL")
query_params = parse_qs(parsed_url.query)
query_params["access_token"] = [session_token]
if state:
query_params["state"] = [state]
new_query = urlencode(query_params, doseq=True)
final_redirect_url = urlunparse(
(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment,
)
if state:
final_redirect_url += f"&state={state}"
else:
# Для других доменов используем старую логику с токеном в URL
from urllib.parse import parse_qs, urlencode, urlunparse
parsed_url = urlparse(redirect_uri)
query_params = parse_qs(parsed_url.query)
# Добавляем access_token и state в URL параметры
query_params["access_token"] = [session_token]
if state:
query_params["state"] = [state]
# Собираем новый URL с параметрами
new_query = urlencode(query_params, doseq=True)
final_redirect_url = urlunparse(
(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment,
)
)
# 🍪 Устанавливаем httpOnly cookie вместо токена в URL
response = RedirectResponse(url=redirect_uri, status_code=307)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE if SESSION_COOKIE_SAMESITE in ["strict", "lax", "none"] else "none",
max_age=SESSION_COOKIE_MAX_AGE,
path="/",
domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами
)
logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}")
logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}")
logger.info(
f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}"
)
# 🔗 Редиректим с токеном в URL
response = RedirectResponse(url=final_redirect_url, status_code=307)
logger.info(f"✅ OAuth: токен передан в URL для user_id={author.id}")
logger.info(f"🔗 Redirect URL: {final_redirect_url}")
logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}")
return response
@@ -811,44 +774,28 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
if not isinstance(redirect_uri, str) or not redirect_uri:
redirect_uri = FRONTEND_URL
# 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект
from urllib.parse import urlparse
# 🎯 Стандартный OAuth flow: токен в URL для фронтенда
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
parsed_redirect = urlparse(redirect_uri)
parsed_url = urlparse(redirect_uri)
# Определяем финальный URL для редиректа
if "testing.discours.io" in parsed_redirect.netloc:
# 💋 Для testing.discours.io используем httpOnly cookie + токен в URL для фронтенда
from urllib.parse import quote
final_redirect_url = (
f"https://testing.discours.io/oauth?access_token={session_token}&redirect_url={quote(redirect_uri)}"
)
if state:
final_redirect_url += f"&state={state}"
else:
# Для других доменов используем старую логику с токеном в URL
from urllib.parse import parse_qs, urlencode, urlunparse
parsed_url = urlparse(redirect_uri)
query_params = parse_qs(parsed_url.query)
# Добавляем access_token и state в URL параметры
query_params["access_token"] = [session_token]
# 🌐 OAuth: токен в URL (стандартный подход)
logger.info("🌐 OAuth: using token in URL")
query_params = parse_qs(parsed_url.query)
query_params["access_token"] = [session_token]
if state:
query_params["state"] = [state]
# Собираем новый URL с параметрами
new_query = urlencode(query_params, doseq=True)
final_redirect_url = urlunparse(
(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment,
)
new_query = urlencode(query_params, doseq=True)
final_redirect_url = urlunparse(
(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment,
)
)
logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}")
@@ -861,30 +808,11 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
logger.info(f" - Provider: {provider}")
logger.info(f" - User ID: {author.id}")
# 🍪 Устанавливаем httpOnly cookie вместо токена в URL
response = RedirectResponse(url=redirect_uri, status_code=307)
# 🔗 Редиректим с токеном в URL
response = RedirectResponse(url=final_redirect_url, status_code=307)
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=session_token,
httponly=SESSION_COOKIE_HTTPONLY,
secure=SESSION_COOKIE_SECURE,
samesite=SESSION_COOKIE_SAMESITE,
max_age=SESSION_COOKIE_MAX_AGE,
path="/",
domain=SESSION_COOKIE_DOMAIN, # ✅ Для работы с поддоменами
)
logger.info(f"✅ OAuth: httpOnly cookie установлен для user_id={author.id}")
logger.info(f"🔗 Redirect на фронтенд БЕЗ токена в URL: {redirect_uri}")
logger.info(
f"🍪 Cookie: {SESSION_COOKIE_NAME}, secure={SESSION_COOKIE_SECURE}, samesite={SESSION_COOKIE_SAMESITE}"
)
logger.info(
f"🔍 Session token preview: {session_token[:30]}..."
if len(session_token) > 30
else f"🔍 Session token: {session_token}"
)
logger.info(f"✅ OAuth: токен передан в URL для user_id={author.id}")
logger.info(f"🔗 Final redirect URL: {final_redirect_url}")
logger.info(f"✅ OAuth успешно завершен для {provider}, user_id={author.id}")
return response