2025-05-31 14:18:31 +00:00
|
|
|
|
# OAuth Implementation Guide
|
|
|
|
|
|
|
|
|
|
## Фронтенд (Текущая реализация)
|
|
|
|
|
|
|
|
|
|
### Контекст сессии
|
|
|
|
|
```typescript
|
|
|
|
|
// src/context/session.tsx
|
|
|
|
|
const oauth = (provider: string) => {
|
|
|
|
|
console.info('[oauth] Starting OAuth flow for provider:', provider)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
if (isServer) {
|
|
|
|
|
console.warn('[oauth] OAuth not available during SSR')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Генерируем state для OAuth
|
|
|
|
|
const state = crypto.randomUUID()
|
|
|
|
|
localStorage.setItem('oauth_state', state)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Формируем URL для OAuth
|
|
|
|
|
const oauthUrl = `${coreApiUrl}/auth/oauth/${provider}?state=${state}&redirect_uri=${encodeURIComponent(window.location.origin)}`
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Перенаправляем на OAuth провайдера
|
|
|
|
|
window.location.href = oauthUrl
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Обработка OAuth callback
|
|
|
|
|
```typescript
|
|
|
|
|
// Обработка OAuth параметров в SessionProvider
|
|
|
|
|
createEffect(
|
2025-06-01 23:56:11 +00:00
|
|
|
|
on([() => searchParams?.state, () => searchParams?.access_token, () => searchParams?.token],
|
2025-05-31 14:18:31 +00:00
|
|
|
|
([state, access_token, token]) => {
|
|
|
|
|
// OAuth обработка
|
|
|
|
|
if (state && access_token) {
|
|
|
|
|
console.info('[SessionProvider] Processing OAuth callback')
|
|
|
|
|
const storedState = !isServer ? localStorage.getItem('oauth_state') : null
|
|
|
|
|
|
|
|
|
|
if (storedState === state) {
|
|
|
|
|
console.info('[SessionProvider] OAuth state verified')
|
|
|
|
|
batch(() => {
|
|
|
|
|
changeSearchParams({ mode: 'confirm-email', m: 'auth', access_token }, { replace: true })
|
|
|
|
|
if (!isServer) localStorage.removeItem('oauth_state')
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
console.warn('[SessionProvider] OAuth state mismatch')
|
|
|
|
|
setAuthError('OAuth state mismatch')
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Обработка токена сброса пароля
|
|
|
|
|
if (token) {
|
|
|
|
|
console.info('[SessionProvider] Processing password reset token')
|
|
|
|
|
changeSearchParams({ mode: 'change-password', m: 'auth', token }, { replace: true })
|
|
|
|
|
}
|
2025-06-01 23:56:11 +00:00
|
|
|
|
},
|
2025-05-31 14:18:31 +00:00
|
|
|
|
{ defer: true }
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Бекенд Requirements
|
|
|
|
|
|
|
|
|
|
### 1. OAuth Endpoints
|
|
|
|
|
|
|
|
|
|
#### GET `/auth/oauth/{provider}`
|
|
|
|
|
```python
|
|
|
|
|
@router.get("/auth/oauth/{provider}")
|
|
|
|
|
async def oauth_redirect(
|
|
|
|
|
provider: str,
|
|
|
|
|
state: str,
|
|
|
|
|
redirect_uri: str,
|
|
|
|
|
request: Request
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Инициация OAuth flow с внешним провайдером
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Args:
|
|
|
|
|
provider: Провайдер OAuth (google, facebook, github)
|
|
|
|
|
state: CSRF токен от клиента
|
|
|
|
|
redirect_uri: URL для редиректа после авторизации
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Returns:
|
|
|
|
|
RedirectResponse: Редирект на провайдера OAuth
|
|
|
|
|
"""
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Валидация провайдера
|
|
|
|
|
if provider not in SUPPORTED_PROVIDERS:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Сохранение state в сессии/Redis для проверки
|
|
|
|
|
await store_oauth_state(state, redirect_uri)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Генерация URL провайдера
|
|
|
|
|
oauth_url = generate_provider_url(provider, state, redirect_uri)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
return RedirectResponse(url=oauth_url)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### GET `/auth/oauth/{provider}/callback`
|
|
|
|
|
```python
|
|
|
|
|
@router.get("/auth/oauth/{provider}/callback")
|
|
|
|
|
async def oauth_callback(
|
|
|
|
|
provider: str,
|
|
|
|
|
code: str,
|
|
|
|
|
state: str,
|
|
|
|
|
request: Request
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Обработка callback от OAuth провайдера
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Args:
|
|
|
|
|
provider: Провайдер OAuth
|
|
|
|
|
code: Authorization code от провайдера
|
|
|
|
|
state: CSRF токен для проверки
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Returns:
|
|
|
|
|
RedirectResponse: Редирект обратно на фронтенд с токеном
|
|
|
|
|
"""
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Проверка state
|
|
|
|
|
stored_data = await get_oauth_state(state)
|
|
|
|
|
if not stored_data:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid or expired state")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Обмен code на access_token
|
|
|
|
|
try:
|
|
|
|
|
user_data = await exchange_code_for_user_data(provider, code)
|
|
|
|
|
except OAuthException as e:
|
|
|
|
|
logger.error(f"OAuth error for {provider}: {e}")
|
|
|
|
|
return RedirectResponse(url=f"{stored_data['redirect_uri']}?error=oauth_failed")
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Поиск/создание пользователя
|
|
|
|
|
user = await get_or_create_user_from_oauth(provider, user_data)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Генерация JWT токена
|
|
|
|
|
access_token = generate_jwt_token(user.id)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Редирект обратно на фронтенд
|
|
|
|
|
redirect_url = f"{stored_data['redirect_uri']}?state={state}&access_token={access_token}"
|
|
|
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. Provider Configuration
|
|
|
|
|
|
|
|
|
|
#### Google OAuth
|
|
|
|
|
```python
|
|
|
|
|
GOOGLE_OAUTH_CONFIG = {
|
|
|
|
|
"client_id": os.getenv("GOOGLE_CLIENT_ID"),
|
|
|
|
|
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET"),
|
|
|
|
|
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
|
|
|
"token_url": "https://oauth2.googleapis.com/token",
|
|
|
|
|
"user_info_url": "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
|
|
|
"scope": "openid email profile"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### Facebook OAuth
|
|
|
|
|
```python
|
|
|
|
|
FACEBOOK_OAUTH_CONFIG = {
|
|
|
|
|
"client_id": os.getenv("FACEBOOK_APP_ID"),
|
|
|
|
|
"client_secret": os.getenv("FACEBOOK_APP_SECRET"),
|
|
|
|
|
"auth_url": "https://www.facebook.com/v18.0/dialog/oauth",
|
|
|
|
|
"token_url": "https://graph.facebook.com/v18.0/oauth/access_token",
|
|
|
|
|
"user_info_url": "https://graph.facebook.com/v18.0/me",
|
|
|
|
|
"scope": "email public_profile"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### GitHub OAuth
|
|
|
|
|
```python
|
|
|
|
|
GITHUB_OAUTH_CONFIG = {
|
|
|
|
|
"client_id": os.getenv("GITHUB_CLIENT_ID"),
|
|
|
|
|
"client_secret": os.getenv("GITHUB_CLIENT_SECRET"),
|
|
|
|
|
"auth_url": "https://github.com/login/oauth/authorize",
|
|
|
|
|
"token_url": "https://github.com/login/oauth/access_token",
|
|
|
|
|
"user_info_url": "https://api.github.com/user",
|
|
|
|
|
"scope": "read:user user:email"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3. User Management
|
|
|
|
|
|
|
|
|
|
#### OAuth User Model
|
|
|
|
|
```python
|
|
|
|
|
class OAuthUser(BaseModel):
|
|
|
|
|
provider: str
|
|
|
|
|
provider_id: str
|
|
|
|
|
email: str
|
|
|
|
|
name: str
|
|
|
|
|
avatar_url: Optional[str] = None
|
|
|
|
|
raw_data: dict
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### User Creation/Linking
|
|
|
|
|
```python
|
|
|
|
|
async def get_or_create_user_from_oauth(
|
2025-06-01 23:56:11 +00:00
|
|
|
|
provider: str,
|
2025-05-31 14:18:31 +00:00
|
|
|
|
oauth_data: OAuthUser
|
|
|
|
|
) -> User:
|
|
|
|
|
"""
|
|
|
|
|
Поиск существующего пользователя или создание нового
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Args:
|
|
|
|
|
provider: OAuth провайдер
|
|
|
|
|
oauth_data: Данные пользователя от провайдера
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
Returns:
|
|
|
|
|
User: Пользователь в системе
|
|
|
|
|
"""
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Поиск по OAuth связке
|
|
|
|
|
oauth_link = await OAuthLink.get_by_provider_and_id(
|
|
|
|
|
provider=provider,
|
|
|
|
|
provider_id=oauth_data.provider_id
|
|
|
|
|
)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
if oauth_link:
|
|
|
|
|
return await User.get(oauth_link.user_id)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Поиск по email
|
|
|
|
|
existing_user = await User.get_by_email(oauth_data.email)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
if existing_user:
|
|
|
|
|
# Привязка OAuth к существующему пользователю
|
|
|
|
|
await OAuthLink.create(
|
|
|
|
|
user_id=existing_user.id,
|
|
|
|
|
provider=provider,
|
|
|
|
|
provider_id=oauth_data.provider_id,
|
|
|
|
|
provider_data=oauth_data.raw_data
|
|
|
|
|
)
|
|
|
|
|
return existing_user
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Создание нового пользователя
|
|
|
|
|
new_user = await User.create(
|
|
|
|
|
email=oauth_data.email,
|
|
|
|
|
name=oauth_data.name,
|
|
|
|
|
pic=oauth_data.avatar_url,
|
|
|
|
|
is_verified=True, # OAuth email считается верифицированным
|
|
|
|
|
registration_method='oauth',
|
|
|
|
|
registration_provider=provider
|
|
|
|
|
)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
# Создание OAuth связки
|
|
|
|
|
await OAuthLink.create(
|
|
|
|
|
user_id=new_user.id,
|
|
|
|
|
provider=provider,
|
|
|
|
|
provider_id=oauth_data.provider_id,
|
|
|
|
|
provider_data=oauth_data.raw_data
|
|
|
|
|
)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
return new_user
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. Security
|
|
|
|
|
|
|
|
|
|
#### State Management
|
|
|
|
|
```python
|
|
|
|
|
import redis
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
|
|
|
|
redis_client = redis.Redis()
|
|
|
|
|
|
|
|
|
|
async def store_oauth_state(
|
2025-06-01 23:56:11 +00:00
|
|
|
|
state: str,
|
|
|
|
|
redirect_uri: str,
|
2025-05-31 14:18:31 +00:00
|
|
|
|
ttl: timedelta = timedelta(minutes=10)
|
|
|
|
|
):
|
|
|
|
|
"""Сохранение OAuth state с TTL"""
|
|
|
|
|
key = f"oauth_state:{state}"
|
|
|
|
|
data = {
|
|
|
|
|
"redirect_uri": redirect_uri,
|
|
|
|
|
"created_at": datetime.utcnow().isoformat()
|
|
|
|
|
}
|
|
|
|
|
await redis_client.setex(key, ttl, json.dumps(data))
|
|
|
|
|
|
|
|
|
|
async def get_oauth_state(state: str) -> Optional[dict]:
|
|
|
|
|
"""Получение и удаление OAuth state"""
|
|
|
|
|
key = f"oauth_state:{state}"
|
|
|
|
|
data = await redis_client.get(key)
|
|
|
|
|
if data:
|
|
|
|
|
await redis_client.delete(key) # One-time use
|
|
|
|
|
return json.loads(data)
|
|
|
|
|
return None
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### CSRF Protection
|
|
|
|
|
```python
|
|
|
|
|
def validate_oauth_state(stored_state: str, received_state: str) -> bool:
|
|
|
|
|
"""Проверка OAuth state для защиты от CSRF"""
|
|
|
|
|
return stored_state == received_state
|
|
|
|
|
|
|
|
|
|
def validate_redirect_uri(uri: str) -> bool:
|
|
|
|
|
"""Валидация redirect_uri для предотвращения открытых редиректов"""
|
|
|
|
|
allowed_domains = [
|
|
|
|
|
"localhost:3000",
|
|
|
|
|
"discours.io",
|
|
|
|
|
"new.discours.io"
|
|
|
|
|
]
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
parsed = urlparse(uri)
|
|
|
|
|
return any(domain in parsed.netloc for domain in allowed_domains)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5. Database Schema
|
|
|
|
|
|
|
|
|
|
#### OAuth Links Table
|
|
|
|
|
```sql
|
|
|
|
|
CREATE TABLE oauth_links (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
|
|
|
provider VARCHAR(50) NOT NULL,
|
|
|
|
|
provider_id VARCHAR(255) NOT NULL,
|
|
|
|
|
provider_data JSONB,
|
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
UNIQUE(provider, provider_id),
|
|
|
|
|
INDEX(user_id),
|
|
|
|
|
INDEX(provider, provider_id)
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 6. Environment Variables
|
|
|
|
|
|
|
|
|
|
#### Required Config
|
|
|
|
|
```bash
|
|
|
|
|
# Google OAuth
|
|
|
|
|
GOOGLE_CLIENT_ID=your_google_client_id
|
|
|
|
|
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Facebook OAuth
|
2025-05-31 14:18:31 +00:00
|
|
|
|
FACEBOOK_APP_ID=your_facebook_app_id
|
|
|
|
|
FACEBOOK_APP_SECRET=your_facebook_app_secret
|
|
|
|
|
|
|
|
|
|
# GitHub OAuth
|
|
|
|
|
GITHUB_CLIENT_ID=your_github_client_id
|
|
|
|
|
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
|
|
|
|
|
|
|
|
# Redis для state management
|
|
|
|
|
REDIS_URL=redis://localhost:6379/0
|
|
|
|
|
|
|
|
|
|
# JWT
|
|
|
|
|
JWT_SECRET=your_jwt_secret_key
|
|
|
|
|
JWT_EXPIRATION_HOURS=24
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 7. Error Handling
|
|
|
|
|
|
|
|
|
|
#### OAuth Exceptions
|
|
|
|
|
```python
|
|
|
|
|
class OAuthException(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
class InvalidProviderException(OAuthException):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
class StateValidationException(OAuthException):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
class ProviderAPIException(OAuthException):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Error responses
|
|
|
|
|
@app.exception_handler(OAuthException)
|
|
|
|
|
async def oauth_exception_handler(request: Request, exc: OAuthException):
|
|
|
|
|
logger.error(f"OAuth error: {exc}")
|
|
|
|
|
return RedirectResponse(
|
|
|
|
|
url=f"{request.base_url}?error=oauth_failed&message={str(exc)}"
|
|
|
|
|
)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 8. Testing
|
|
|
|
|
|
|
|
|
|
#### Unit Tests
|
|
|
|
|
```python
|
|
|
|
|
def test_oauth_redirect():
|
|
|
|
|
response = client.get("/auth/oauth/google?state=test&redirect_uri=http://localhost:3000")
|
|
|
|
|
assert response.status_code == 307
|
|
|
|
|
assert "accounts.google.com" in response.headers["location"]
|
|
|
|
|
|
|
|
|
|
def test_oauth_callback():
|
|
|
|
|
# Mock provider response
|
|
|
|
|
with mock.patch('oauth.exchange_code_for_user_data') as mock_exchange:
|
|
|
|
|
mock_exchange.return_value = OAuthUser(
|
|
|
|
|
provider="google",
|
|
|
|
|
provider_id="123456",
|
|
|
|
|
email="test@example.com",
|
|
|
|
|
name="Test User"
|
|
|
|
|
)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
response = client.get("/auth/oauth/google/callback?code=test_code&state=test_state")
|
|
|
|
|
assert response.status_code == 307
|
|
|
|
|
assert "access_token=" in response.headers["location"]
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Frontend Testing
|
|
|
|
|
|
|
|
|
|
### E2E Tests
|
|
|
|
|
```typescript
|
|
|
|
|
// tests/oauth.spec.ts
|
|
|
|
|
test('OAuth flow with Google', async ({ page }) => {
|
|
|
|
|
await page.goto('/login')
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Click Google OAuth button
|
|
|
|
|
await page.click('[data-testid="oauth-google"]')
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Should redirect to Google
|
|
|
|
|
await page.waitForURL(/accounts\.google\.com/)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Mock successful OAuth (in test environment)
|
|
|
|
|
await page.goto('/?state=test&access_token=mock_token')
|
2025-06-01 23:56:11 +00:00
|
|
|
|
|
2025-05-31 14:18:31 +00:00
|
|
|
|
// Should be logged in
|
|
|
|
|
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible()
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Deployment Checklist
|
|
|
|
|
|
|
|
|
|
- [ ] Зарегистрировать OAuth приложения у провайдеров
|
|
|
|
|
- [ ] Настроить redirect URLs в консолях провайдеров
|
|
|
|
|
- [ ] Добавить environment variables
|
|
|
|
|
- [ ] Настроить Redis для state management
|
|
|
|
|
- [ ] Создать таблицу oauth_links
|
|
|
|
|
- [ ] Добавить rate limiting для OAuth endpoints
|
|
|
|
|
- [ ] Настроить мониторинг OAuth ошибок
|
|
|
|
|
- [ ] Протестировать все провайдеры в staging
|
2025-06-01 23:56:11 +00:00
|
|
|
|
- [ ] Добавить логирование OAuth событий
|