nordabiz/blueprints/api/routes_oauth.py
Maciej Pienczyn 66cd223568
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
feat(oauth): Phase 3 foundation - OAuth 2.0 framework for external APIs
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>
2026-02-08 11:46:42 +01:00

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()