Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
New files: - oauth_service.py: Shared OAuth 2.0 service supporting Google and Meta providers with token exchange, refresh, and storage - database/migrations/058_oauth_tokens.sql: oauth_tokens table with company/provider/service unique constraint - blueprints/api/routes_oauth.py: OAuth API endpoints for connect, callback, status, and disconnect flows Supports: - Google OAuth (GBP Business Profile, Search Console) - Meta OAuth (Facebook Pages, Instagram) - CSRF state validation, token refresh, expiry tracking - Per-company token storage with active/inactive status Requires .env config: - GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET (Google APIs) - META_APP_ID, META_APP_SECRET (Facebook/Instagram) - OAUTH_REDIRECT_BASE_URL (default: https://nordabiznes.pl) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
185 lines
6.2 KiB
Python
185 lines
6.2 KiB
Python
"""
|
|
OAuth API Routes
|
|
================
|
|
|
|
Endpoints for connecting external accounts (Google, Meta) via OAuth 2.0.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from flask import jsonify, request, redirect, session
|
|
from flask_login import login_required, current_user
|
|
|
|
from . import bp
|
|
from database import SessionLocal, OAuthToken
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@bp.route('/oauth/connect/<provider>/<service>', methods=['POST'])
|
|
@login_required
|
|
def oauth_connect(provider, service):
|
|
"""Initiate OAuth flow for connecting an external account.
|
|
|
|
POST /api/oauth/connect/google/gbp
|
|
POST /api/oauth/connect/meta/facebook
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
# Validate provider/service
|
|
valid_combinations = {
|
|
('google', 'gbp'), ('google', 'search_console'),
|
|
('meta', 'facebook'), ('meta', 'instagram'),
|
|
}
|
|
if (provider, service) not in valid_combinations:
|
|
return jsonify({'success': False, 'error': f'Nieznany provider/service: {provider}/{service}'}), 400
|
|
|
|
# User must have a company
|
|
if not current_user.company_id:
|
|
return jsonify({'success': False, 'error': 'Musisz być przypisany do firmy'}), 403
|
|
|
|
# Generate CSRF state token
|
|
state = f"{current_user.company_id}:{current_user.id}:{service}:{secrets.token_urlsafe(16)}"
|
|
session['oauth_state'] = state
|
|
|
|
oauth = OAuthService()
|
|
auth_url = oauth.get_authorization_url(provider, service, state)
|
|
|
|
if not auth_url:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': f'OAuth nie skonfigurowany dla {provider}. Skontaktuj się z administratorem.'
|
|
}), 503
|
|
|
|
return jsonify({'success': True, 'auth_url': auth_url})
|
|
|
|
|
|
@bp.route('/oauth/callback/<provider>', methods=['GET'])
|
|
@login_required
|
|
def oauth_callback(provider):
|
|
"""Handle OAuth callback from provider.
|
|
|
|
GET /api/oauth/callback/google?code=...&state=...
|
|
GET /api/oauth/callback/meta?code=...&state=...
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
code = request.args.get('code')
|
|
state = request.args.get('state')
|
|
error = request.args.get('error')
|
|
|
|
if error:
|
|
logger.warning(f"OAuth error from {provider}: {error}")
|
|
return redirect(f'/admin/company?oauth_error={error}')
|
|
|
|
if not code or not state:
|
|
return redirect('/admin/company?oauth_error=missing_params')
|
|
|
|
# Validate state
|
|
saved_state = session.pop('oauth_state', None)
|
|
if not saved_state or saved_state != state:
|
|
logger.warning(f"OAuth state mismatch for {provider}")
|
|
return redirect('/admin/company?oauth_error=invalid_state')
|
|
|
|
# Parse state: company_id:user_id:service:random
|
|
try:
|
|
parts = state.split(':')
|
|
company_id = int(parts[0])
|
|
user_id = int(parts[1])
|
|
service = parts[2]
|
|
except (ValueError, IndexError):
|
|
return redirect('/admin/company?oauth_error=invalid_state_format')
|
|
|
|
# Verify user owns this company
|
|
if current_user.id != user_id or current_user.company_id != company_id:
|
|
return redirect('/admin/company?oauth_error=unauthorized')
|
|
|
|
# Exchange code for token
|
|
oauth = OAuthService()
|
|
token_data = oauth.exchange_code(provider, code)
|
|
|
|
if not token_data:
|
|
return redirect('/admin/company?oauth_error=token_exchange_failed')
|
|
|
|
# Save token
|
|
db = SessionLocal()
|
|
try:
|
|
success = oauth.save_token(db, company_id, user_id, provider, service, token_data)
|
|
if success:
|
|
logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}")
|
|
return redirect(f'/admin/company?oauth_success={provider}/{service}')
|
|
else:
|
|
return redirect('/admin/company?oauth_error=save_failed')
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/oauth/status', methods=['GET'])
|
|
@login_required
|
|
def oauth_status():
|
|
"""Get connected OAuth services for current user's company.
|
|
|
|
GET /api/oauth/status
|
|
"""
|
|
from oauth_service import OAuthService
|
|
|
|
if not current_user.company_id:
|
|
return jsonify({'success': True, 'services': {}})
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
oauth = OAuthService()
|
|
services = oauth.get_connected_services(db, current_user.company_id)
|
|
|
|
# Add available (but not connected) services
|
|
all_services = {
|
|
'google/gbp': {'name': 'Google Business Profile', 'description': 'Pełne dane o wizytówce, opinie, insights'},
|
|
'google/search_console': {'name': 'Google Search Console', 'description': 'Zapytania, CTR, pozycje w wyszukiwaniu'},
|
|
'meta/facebook': {'name': 'Facebook', 'description': 'Reach, impressions, demographics, post insights'},
|
|
'meta/instagram': {'name': 'Instagram', 'description': 'Stories, reels, engagement metrics'},
|
|
}
|
|
|
|
result = {}
|
|
for key, info in all_services.items():
|
|
connected = services.get(key, {})
|
|
result[key] = {
|
|
**info,
|
|
'connected': bool(connected),
|
|
'account_name': connected.get('account_name'),
|
|
'expires_at': connected.get('expires_at'),
|
|
'is_expired': connected.get('is_expired', False),
|
|
}
|
|
|
|
return jsonify({'success': True, 'services': result})
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/oauth/disconnect/<provider>/<service>', methods=['POST'])
|
|
@login_required
|
|
def oauth_disconnect(provider, service):
|
|
"""Disconnect an OAuth service.
|
|
|
|
POST /api/oauth/disconnect/google/gbp
|
|
"""
|
|
if not current_user.company_id:
|
|
return jsonify({'success': False, 'error': 'Brak przypisanej firmy'}), 403
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
token = db.query(OAuthToken).filter(
|
|
OAuthToken.company_id == current_user.company_id,
|
|
OAuthToken.provider == provider,
|
|
OAuthToken.service == service,
|
|
).first()
|
|
|
|
if token:
|
|
token.is_active = False
|
|
db.commit()
|
|
logger.info(f"OAuth disconnected: {provider}/{service} for company {current_user.company_id}")
|
|
return jsonify({'success': True, 'message': f'{provider}/{service} rozłączony'})
|
|
else:
|
|
return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404
|
|
finally:
|
|
db.close()
|