# OAuth Frontend Integration для testing.discours.io ## 🎯 Схема: JWT в URL + httpOnly Cookie ### 📋 Полный flow: 1. **OAuth success** → бэкенд генерирует JWT 2. **Редирект**: `/oauth?access_token=JWT&redirect_url=...` + httpOnly cookie 3. **Фронт роут**: `localStorage.setItem('auth_token', token)` 4. **SessionProvider**: `loadSession()` → использует localStorage токен 5. **GraphQL клиент**: `credentials: 'include'` → использует httpOnly cookie ## 🔧 Frontend Implementation ### 1. OAuth Route Handler (`/oauth`) ```typescript // routes/oauth.tsx import { useEffect } from 'solid-js' import { useNavigate, useSearchParams } from '@solidjs/router' import { useSession } from '../context/SessionProvider' export default function OAuthCallback() { const navigate = useNavigate() const [searchParams] = useSearchParams() const { loadSession } = useSession() useEffect(async () => { const error = searchParams.error const accessToken = searchParams.access_token const redirectUrl = searchParams.redirect_url || '/' if (error) { // Обработка ошибок OAuth console.error('OAuth error:', error) // Показываем пользователю ошибку if (error === 'oauth_state_expired') { alert('OAuth session expired. Please try logging in again.') } else if (error === 'access_denied') { alert('Access denied by provider.') } else { alert('Authentication failed. Please try again.') } navigate('/') return } if (accessToken) { try { // 1. Сохраняем JWT в localStorage для быстрого доступа localStorage.setItem('auth_token', accessToken) // 2. SessionProvider загружает сессию (использует localStorage токен) await loadSession() // 3. Очищаем URL от токена (безопасность) window.history.replaceState({}, document.title, '/oauth-success') // 4. Редиректим на исходную страницу через 1 секунду setTimeout(() => { navigate(decodeURIComponent(redirectUrl)) }, 1000) } catch (error) { console.error('Failed to load session:', error) localStorage.removeItem('auth_token') navigate('/') } } else { // Неожиданный случай navigate('/') } }) return (

Completing authentication...

) } ``` ### 2. OAuth Initiation ```typescript // utils/auth.ts export const oauth = (provider: string) => { // Простой редирект - backend получит redirect_uri из Referer header window.location.href = `https://v3.dscrs.site/oauth/${provider}` } // Использование в компонентах import { oauth } from '../utils/auth' const LoginButton = () => ( ) ``` ### 3. Session Provider ```typescript // context/SessionProvider.tsx import { createContext, useContext, createSignal, onMount } from 'solid-js' interface SessionContextType { user: () => User | null isAuthenticated: () => boolean loadSession: () => Promise logout: () => void } const SessionContext = createContext() export function SessionProvider(props: { children: any }) { const [user, setUser] = createSignal(null) const isAuthenticated = () => !!user() const loadSession = async () => { try { // Проверяем localStorage токен const token = localStorage.getItem('auth_token') if (!token) { setUser(null) return } // Загружаем профиль пользователя через GraphQL (использует httpOnly cookie) const response = await client.query({ query: GET_CURRENT_USER, fetchPolicy: 'network-only' // Всегда свежие данные }) if (response.data?.currentUser) { setUser(response.data.currentUser) } else { // Токен невалидный, очищаем localStorage.removeItem('auth_token') setUser(null) } } catch (error) { console.error('Failed to load session:', error) localStorage.removeItem('auth_token') setUser(null) } } const logout = () => { localStorage.removeItem('auth_token') setUser(null) // Опционально: вызов logout endpoint для очистки httpOnly cookie fetch('https://v3.dscrs.site/auth/logout', { method: 'POST', credentials: 'include' }) } // Загружаем сессию при инициализации onMount(() => { loadSession() }) return ( {props.children} ) } export const useSession = () => { const context = useContext(SessionContext) if (!context) { throw new Error('useSession must be used within SessionProvider') } return context } ``` ### 4. GraphQL Client Setup ```typescript // graphql/client.ts import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client' const httpLink = createHttpLink({ uri: 'https://v3.dscrs.site/graphql', credentials: 'include', // ✅ КРИТИЧНО: отправляет httpOnly cookie }) export const client = new ApolloClient({ link: httpLink, cache: new InMemoryCache(), defaultOptions: { watchQuery: { errorPolicy: 'all' }, query: { errorPolicy: 'all' } } }) ``` ### 5. API Client для прямых вызовов ```typescript // utils/api.ts class ApiClient { private baseUrl = 'https://v3.dscrs.site' private getAuthHeaders() { const token = localStorage.getItem('auth_token') return token ? { 'Authorization': `Bearer ${token}` } : {} } async request(endpoint: string, options: RequestInit = {}) { const response = await fetch(`${this.baseUrl}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders(), ...options.headers }, credentials: 'include' // Для httpOnly cookie }) if (!response.ok) { if (response.status === 401) { // Токен истек, очищаем localStorage localStorage.removeItem('auth_token') window.location.href = '/login' } throw new Error(`API Error: ${response.status}`) } return response.json() } // Методы для различных API calls async uploadFile(file: File) { const formData = new FormData() formData.append('file', file) return this.request('/upload', { method: 'POST', body: formData, headers: this.getAuthHeaders() // Только Authorization header, без Content-Type }) } } export const apiClient = new ApiClient() ``` ## 🔒 Безопасность ### Преимущества двойной схемы: 1. **httpOnly Cookie** - защита от XSS для GraphQL 2. **localStorage JWT** - быстрый доступ для API calls 3. **Automatic cleanup** - токен удаляется при ошибках 401 4. **URL cleanup** - токен не остается в истории браузера ### Обработка ошибок: ```typescript // utils/errorHandler.ts export const handleAuthError = (error: any) => { if (error.networkError?.statusCode === 401) { // Токен истек localStorage.removeItem('auth_token') window.location.href = '/login' } } // В Apollo Client import { onError } from '@apollo/client/link/error' const errorLink = onError(({ graphQLErrors, networkError }) => { if (networkError?.statusCode === 401) { handleAuthError(networkError) } }) ``` ## 🧪 Testing ### E2E Test ```typescript // tests/oauth.spec.ts import { test, expect } from '@playwright/test' test('OAuth flow works correctly', async ({ page }) => { // 1. Инициация OAuth await page.goto('https://testing.discours.io') await page.click('[data-testid="github-login"]') // 2. Проверяем редирект на GitHub await expect(page).toHaveURL(/github\.com\/login\/oauth\/authorize/) // 3. Симулируем успешный callback await page.goto('https://testing.discours.io/oauth?access_token=test_jwt&redirect_url=%2Fdashboard') // 4. Проверяем что токен сохранился const token = await page.evaluate(() => localStorage.getItem('auth_token')) expect(token).toBe('test_jwt') // 5. Проверяем редирект на dashboard await expect(page).toHaveURL('https://testing.discours.io/dashboard') }) ``` ## 📊 Monitoring ### Метрики для отслеживания: - OAuth success rate - Token validation errors - Session load time - Cookie/localStorage sync issues