326 lines
9.1 KiB
Markdown
326 lines
9.1 KiB
Markdown
|
|
# 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 (
|
|||
|
|
<div class="oauth-callback">
|
|||
|
|
<div class="loading">
|
|||
|
|
<h2>Completing authentication...</h2>
|
|||
|
|
<div class="spinner"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 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 = () => (
|
|||
|
|
<button onClick={() => oauth('github')}>
|
|||
|
|
Login with GitHub
|
|||
|
|
</button>
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3. Session Provider
|
|||
|
|
```typescript
|
|||
|
|
// context/SessionProvider.tsx
|
|||
|
|
import { createContext, useContext, createSignal, onMount } from 'solid-js'
|
|||
|
|
|
|||
|
|
interface SessionContextType {
|
|||
|
|
user: () => User | null
|
|||
|
|
isAuthenticated: () => boolean
|
|||
|
|
loadSession: () => Promise<void>
|
|||
|
|
logout: () => void
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const SessionContext = createContext<SessionContextType>()
|
|||
|
|
|
|||
|
|
export function SessionProvider(props: { children: any }) {
|
|||
|
|
const [user, setUser] = createSignal<User | null>(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 (
|
|||
|
|
<SessionContext.Provider value={{
|
|||
|
|
user,
|
|||
|
|
isAuthenticated,
|
|||
|
|
loadSession,
|
|||
|
|
logout
|
|||
|
|
}}>
|
|||
|
|
{props.children}
|
|||
|
|
</SessionContext.Provider>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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
|