feat(oauth): Phase 3 foundation - OAuth 2.0 framework for external APIs
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
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>
This commit is contained in:
parent
279947d4aa
commit
66cd223568
@ -18,3 +18,4 @@ from . import routes_social_audit # noqa: E402, F401
|
|||||||
from . import routes_company # noqa: E402, F401
|
from . import routes_company # noqa: E402, F401
|
||||||
from . import routes_membership # noqa: E402, F401
|
from . import routes_membership # noqa: E402, F401
|
||||||
from . import routes_audit_actions # noqa: E402, F401
|
from . import routes_audit_actions # noqa: E402, F401
|
||||||
|
from . import routes_oauth # noqa: E402, F401
|
||||||
|
|||||||
184
blueprints/api/routes_oauth.py
Normal file
184
blueprints/api/routes_oauth.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
37
database.py
37
database.py
@ -5182,6 +5182,43 @@ class SocialConnection(Base):
|
|||||||
return f"<SocialConnection {self.id} company={self.company_id} platform={self.platform}>"
|
return f"<SocialConnection {self.id} company={self.company_id} platform={self.platform}>"
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthToken(Base):
|
||||||
|
"""OAuth tokens for external API integrations (Google, Meta)."""
|
||||||
|
__tablename__ = 'oauth_tokens'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
company_id = Column(Integer, ForeignKey('companies.id'), nullable=False)
|
||||||
|
company = relationship('Company', backref='oauth_tokens')
|
||||||
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
user = relationship('User', backref='oauth_tokens')
|
||||||
|
provider = Column(String(50), nullable=False) # google, meta
|
||||||
|
service = Column(String(50), nullable=False) # gbp, search_console, facebook, instagram
|
||||||
|
access_token = Column(Text, nullable=False)
|
||||||
|
refresh_token = Column(Text)
|
||||||
|
token_type = Column(String(50), default='Bearer')
|
||||||
|
expires_at = Column(DateTime)
|
||||||
|
scopes = Column(Text)
|
||||||
|
account_id = Column(String(255))
|
||||||
|
account_name = Column(String(255))
|
||||||
|
metadata_json = Column('metadata', PG_JSONB)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('company_id', 'provider', 'service', name='uq_oauth_company_provider_service'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
if not self.expires_at:
|
||||||
|
return False
|
||||||
|
return datetime.now() > self.expires_at
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<OAuthToken {self.provider}/{self.service} for company_id={self.company_id}>'
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DATABASE INITIALIZATION
|
# DATABASE INITIALIZATION
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
29
database/migrations/058_oauth_tokens.sql
Normal file
29
database/migrations/058_oauth_tokens.sql
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
-- OAuth Tokens for external API integrations
|
||||||
|
-- Supports: Google (GBP Business Profile, Search Console), Meta (Facebook, Instagram)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
provider VARCHAR(50) NOT NULL, -- 'google', 'meta'
|
||||||
|
service VARCHAR(50) NOT NULL, -- 'gbp', 'search_console', 'facebook', 'instagram'
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_type VARCHAR(50) DEFAULT 'Bearer',
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
scopes TEXT, -- space-separated scopes
|
||||||
|
account_id VARCHAR(255), -- external account/page ID
|
||||||
|
account_name VARCHAR(255), -- external account/page name
|
||||||
|
metadata JSONB, -- additional provider-specific data
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(company_id, provider, service)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_oauth_tokens_company ON oauth_tokens(company_id);
|
||||||
|
CREATE INDEX idx_oauth_tokens_provider ON oauth_tokens(provider, service);
|
||||||
|
|
||||||
|
-- Grant permissions
|
||||||
|
GRANT ALL ON TABLE oauth_tokens TO nordabiz_app;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE oauth_tokens_id_seq TO nordabiz_app;
|
||||||
@ -40,20 +40,20 @@
|
|||||||
- [ ] Migracja bazy danych (nowe kolumny JSONB — opcjonalne, dane w result dict)
|
- [ ] Migracja bazy danych (nowe kolumny JSONB — opcjonalne, dane w result dict)
|
||||||
- [ ] Zaktualizować szablony HTML (wyświetlanie atrybutów)
|
- [ ] Zaktualizować szablony HTML (wyświetlanie atrybutów)
|
||||||
|
|
||||||
### Faza 3: OAuth Framework (0 PLN API, 2-4 tygodnie dev)
|
### Faza 3: OAuth Framework (0 PLN API, 2-4 tygodnie dev) — FUNDAMENT UKOŃCZONY (2026-02-08)
|
||||||
- [ ] Shared OAuth 2.0 framework (`oauth_service.py`)
|
- [x] Shared OAuth 2.0 framework (`oauth_service.py`) — Google + Meta providers
|
||||||
|
- [x] Tabela `oauth_tokens` w DB (migracja 058)
|
||||||
|
- [x] Model `OAuthToken` w database.py
|
||||||
|
- [x] API endpoints: `/api/oauth/connect`, `/api/oauth/callback`, `/api/oauth/status`, `/api/oauth/disconnect`
|
||||||
- [ ] GBP Business Profile API:
|
- [ ] GBP Business Profile API:
|
||||||
- Scope: `business.manage`, App review ~14 dni, darmowe
|
- Scope: `business.manage`, App review ~14 dni, darmowe
|
||||||
- Daje: WSZYSTKIE opinie (nie max 5), owner responses, insights (views/clicks/calls/keywords), posty
|
- Wymaga: GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET w .env
|
||||||
|
- Daje: WSZYSTKIE opinie, owner responses, insights, posty
|
||||||
- [ ] Facebook + Instagram Graph API:
|
- [ ] Facebook + Instagram Graph API:
|
||||||
- Wspólny OAuth via Meta, App review 3-7 dni
|
- Wymaga: META_APP_ID, META_APP_SECRET w .env + App review 3-7 dni
|
||||||
- Scopes: pages_show_list, pages_read_engagement, read_insights, instagram_basic, instagram_manage_insights
|
|
||||||
- Daje: reach, impressions, demographics, post insights, IG stories/reels
|
- Daje: reach, impressions, demographics, post insights, IG stories/reels
|
||||||
- Token: Long-Lived (90 dni), Page Token (nigdy nie wygasa)
|
|
||||||
- [ ] Google Search Console API (per firma OAuth, darmowe)
|
- [ ] Google Search Console API (per firma OAuth, darmowe)
|
||||||
- Daje: zapytania wyszukiwania, CTR, pozycje, status indeksacji
|
- [ ] UI: "Połącz konto" w panelu firmy (frontend)
|
||||||
- [ ] UI: "Połącz konto" w panelu firmy
|
|
||||||
- [ ] Tabela `oauth_tokens` w DB
|
|
||||||
|
|
||||||
### Faza 4: Zaawansowane (opcjonalne)
|
### Faza 4: Zaawansowane (opcjonalne)
|
||||||
- [ ] Sentiment analysis recenzji via Gemini
|
- [ ] Sentiment analysis recenzji via Gemini
|
||||||
@ -98,9 +98,11 @@
|
|||||||
|
|
||||||
## Wpływ na Kompletność
|
## Wpływ na Kompletność
|
||||||
|
|
||||||
| | Obecny | F0 | F1 | F2 | F3 |
|
| | Początkowy | F0 ✅ | F1 ✅ | F2 ✅ | F3 (plan) |
|
||||||
|---|--------|-----|-----|-----|-----|
|
|---|--------|-----|-----|-----|-----|
|
||||||
| GBP | 55% | 60% | 75% | 90% | 98% |
|
| GBP | 55% | 60% | 75% | **90%** | 98% |
|
||||||
| SEO | 60% | 75% | 85% | 85% | 95% |
|
| SEO | 60% | 75% | **85%** | 85% | 95% |
|
||||||
| Social | 35% | 50% | 65% | 65% | 85% |
|
| Social | 35% | 50% | **65%** | 65% | 85% |
|
||||||
| **Średnia** | **52%** | **68%** | **78%** | **83%** | **93%** |
|
| **Średnia** | **52%** | **68%** | **78%** | **~83%** | **93%** |
|
||||||
|
|
||||||
|
**Status (2026-02-08):** F0+F1+F2 ukończone. Obecna kompletność: ~83%. Pozostała: F3 (OAuth).
|
||||||
|
|||||||
331
oauth_service.py
Normal file
331
oauth_service.py
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
Shared OAuth 2.0 Service for NordaBiz
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Google OAuth 2.0 (GBP Business Profile API, Search Console API)
|
||||||
|
- Meta OAuth 2.0 (Facebook Graph API, Instagram Graph API)
|
||||||
|
|
||||||
|
Config via environment variables:
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
|
- META_APP_ID, META_APP_SECRET
|
||||||
|
- OAUTH_REDIRECT_BASE_URL (e.g., https://nordabiznes.pl)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth Provider Configurations
|
||||||
|
OAUTH_PROVIDERS = {
|
||||||
|
'google': {
|
||||||
|
'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
'token_url': 'https://oauth2.googleapis.com/token',
|
||||||
|
'scopes': {
|
||||||
|
'gbp': 'https://www.googleapis.com/auth/business.manage',
|
||||||
|
'search_console': 'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'meta': {
|
||||||
|
'auth_url': 'https://www.facebook.com/v21.0/dialog/oauth',
|
||||||
|
'token_url': 'https://graph.facebook.com/v21.0/oauth/access_token',
|
||||||
|
'scopes': {
|
||||||
|
'facebook': 'pages_show_list,pages_read_engagement,read_insights',
|
||||||
|
'instagram': 'instagram_basic,instagram_manage_insights,pages_show_list',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthService:
|
||||||
|
"""Shared OAuth 2.0 service for external API integrations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.google_client_id = os.getenv('GOOGLE_OAUTH_CLIENT_ID')
|
||||||
|
self.google_client_secret = os.getenv('GOOGLE_OAUTH_CLIENT_SECRET')
|
||||||
|
self.meta_app_id = os.getenv('META_APP_ID')
|
||||||
|
self.meta_app_secret = os.getenv('META_APP_SECRET')
|
||||||
|
self.redirect_base = os.getenv('OAUTH_REDIRECT_BASE_URL', 'https://nordabiznes.pl')
|
||||||
|
|
||||||
|
def get_authorization_url(self, provider: str, service: str, state: str) -> Optional[str]:
|
||||||
|
"""Generate OAuth authorization URL for a provider/service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: 'google' or 'meta'
|
||||||
|
service: 'gbp', 'search_console', 'facebook', 'instagram'
|
||||||
|
state: CSRF state token (include company_id and user_id encoded)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authorization URL or None if provider not configured
|
||||||
|
"""
|
||||||
|
config = OAUTH_PROVIDERS.get(provider)
|
||||||
|
if not config:
|
||||||
|
logger.error(f"Unknown OAuth provider: {provider}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
scopes = config['scopes'].get(service, '')
|
||||||
|
redirect_uri = f"{self.redirect_base}/api/oauth/callback/{provider}"
|
||||||
|
|
||||||
|
if provider == 'google':
|
||||||
|
if not self.google_client_id:
|
||||||
|
logger.warning("Google OAuth not configured (GOOGLE_OAUTH_CLIENT_ID)")
|
||||||
|
return None
|
||||||
|
params = {
|
||||||
|
'client_id': self.google_client_id,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'response_type': 'code',
|
||||||
|
'scope': scopes,
|
||||||
|
'state': state,
|
||||||
|
'access_type': 'offline',
|
||||||
|
'prompt': 'consent',
|
||||||
|
}
|
||||||
|
elif provider == 'meta':
|
||||||
|
if not self.meta_app_id:
|
||||||
|
logger.warning("Meta OAuth not configured (META_APP_ID)")
|
||||||
|
return None
|
||||||
|
params = {
|
||||||
|
'client_id': self.meta_app_id,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'response_type': 'code',
|
||||||
|
'scope': scopes,
|
||||||
|
'state': state,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = '&'.join(f'{k}={requests.utils.quote(str(v))}' for k, v in params.items())
|
||||||
|
return f"{config['auth_url']}?{query}"
|
||||||
|
|
||||||
|
def exchange_code(self, provider: str, code: str) -> Optional[Dict]:
|
||||||
|
"""Exchange authorization code for access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: 'google' or 'meta'
|
||||||
|
code: Authorization code from callback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with access_token, refresh_token, expires_in or None on error
|
||||||
|
"""
|
||||||
|
config = OAUTH_PROVIDERS.get(provider)
|
||||||
|
if not config:
|
||||||
|
return None
|
||||||
|
|
||||||
|
redirect_uri = f"{self.redirect_base}/api/oauth/callback/{provider}"
|
||||||
|
|
||||||
|
if provider == 'google':
|
||||||
|
data = {
|
||||||
|
'code': code,
|
||||||
|
'client_id': self.google_client_id,
|
||||||
|
'client_secret': self.google_client_secret,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
}
|
||||||
|
elif provider == 'meta':
|
||||||
|
data = {
|
||||||
|
'code': code,
|
||||||
|
'client_id': self.meta_app_id,
|
||||||
|
'client_secret': self.meta_app_secret,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(config['token_url'], data=data, timeout=15)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"OAuth token exchange failed for {provider}: {response.status_code} - {response.text}")
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OAuth token exchange error for {provider}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh_access_token(self, provider: str, refresh_token: str) -> Optional[Dict]:
|
||||||
|
"""Refresh an expired access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: 'google' or 'meta'
|
||||||
|
refresh_token: The refresh token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with new access_token, expires_in or None
|
||||||
|
"""
|
||||||
|
if provider == 'google':
|
||||||
|
data = {
|
||||||
|
'client_id': self.google_client_id,
|
||||||
|
'client_secret': self.google_client_secret,
|
||||||
|
'refresh_token': refresh_token,
|
||||||
|
'grant_type': 'refresh_token',
|
||||||
|
}
|
||||||
|
token_url = OAUTH_PROVIDERS['google']['token_url']
|
||||||
|
elif provider == 'meta':
|
||||||
|
# Meta long-lived tokens: exchange short-lived for long-lived
|
||||||
|
params = {
|
||||||
|
'grant_type': 'fb_exchange_token',
|
||||||
|
'client_id': self.meta_app_id,
|
||||||
|
'client_secret': self.meta_app_secret,
|
||||||
|
'fb_exchange_token': refresh_token,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
OAUTH_PROVIDERS['meta']['token_url'],
|
||||||
|
params=params,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Meta token refresh error: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(token_url, data=data, timeout=15)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
logger.error(f"Token refresh failed for {provider}: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh error for {provider}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save_token(self, db, company_id: int, user_id: int, provider: str,
|
||||||
|
service: str, token_data: Dict) -> bool:
|
||||||
|
"""Save or update OAuth token in database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
company_id: Company ID
|
||||||
|
user_id: User who authorized
|
||||||
|
provider: 'google' or 'meta'
|
||||||
|
service: 'gbp', 'search_console', 'facebook', 'instagram'
|
||||||
|
token_data: Dict from exchange_code() or refresh_access_token()
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if saved successfully
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from database import OAuthToken
|
||||||
|
|
||||||
|
# Find existing token or create new
|
||||||
|
token = db.query(OAuthToken).filter(
|
||||||
|
OAuthToken.company_id == company_id,
|
||||||
|
OAuthToken.provider == provider,
|
||||||
|
OAuthToken.service == service,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
token = OAuthToken(
|
||||||
|
company_id=company_id,
|
||||||
|
user_id=user_id,
|
||||||
|
provider=provider,
|
||||||
|
service=service,
|
||||||
|
)
|
||||||
|
db.add(token)
|
||||||
|
|
||||||
|
token.access_token = token_data.get('access_token', '')
|
||||||
|
token.refresh_token = token_data.get('refresh_token', token.refresh_token)
|
||||||
|
token.token_type = token_data.get('token_type', 'Bearer')
|
||||||
|
|
||||||
|
expires_in = token_data.get('expires_in')
|
||||||
|
if expires_in:
|
||||||
|
token.expires_at = datetime.now() + timedelta(seconds=int(expires_in))
|
||||||
|
|
||||||
|
token.scopes = OAUTH_PROVIDERS.get(provider, {}).get('scopes', {}).get(service, '')
|
||||||
|
token.is_active = True
|
||||||
|
token.updated_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"OAuth token saved: {provider}/{service} for company {company_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error saving OAuth token: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_valid_token(self, db, company_id: int, provider: str, service: str) -> Optional[str]:
|
||||||
|
"""Get a valid access token, refreshing if needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
company_id: Company ID
|
||||||
|
provider: 'google' or 'meta'
|
||||||
|
service: Service name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Valid access token string or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from database import OAuthToken
|
||||||
|
|
||||||
|
token = db.query(OAuthToken).filter(
|
||||||
|
OAuthToken.company_id == company_id,
|
||||||
|
OAuthToken.provider == provider,
|
||||||
|
OAuthToken.service == service,
|
||||||
|
OAuthToken.is_active == True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if token is expired
|
||||||
|
if token.is_expired and token.refresh_token:
|
||||||
|
new_data = self.refresh_access_token(provider, token.refresh_token)
|
||||||
|
if new_data:
|
||||||
|
token.access_token = new_data.get('access_token', token.access_token)
|
||||||
|
expires_in = new_data.get('expires_in')
|
||||||
|
if expires_in:
|
||||||
|
token.expires_at = datetime.now() + timedelta(seconds=int(expires_in))
|
||||||
|
token.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
# Refresh failed, mark as inactive
|
||||||
|
token.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token.access_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting valid token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_connected_services(self, db, company_id: int) -> Dict[str, Dict]:
|
||||||
|
"""Get status of all connected OAuth services for a company.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict like {'google/gbp': {'connected': True, 'account_name': '...', 'expires_at': ...}, ...}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from database import OAuthToken
|
||||||
|
|
||||||
|
tokens = db.query(OAuthToken).filter(
|
||||||
|
OAuthToken.company_id == company_id,
|
||||||
|
OAuthToken.is_active == True,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for token in tokens:
|
||||||
|
key = f"{token.provider}/{token.service}"
|
||||||
|
result[key] = {
|
||||||
|
'connected': True,
|
||||||
|
'account_name': token.account_name,
|
||||||
|
'account_id': token.account_id,
|
||||||
|
'expires_at': token.expires_at.isoformat() if token.expires_at else None,
|
||||||
|
'is_expired': token.is_expired,
|
||||||
|
'updated_at': token.updated_at.isoformat() if token.updated_at else None,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting connected services: {e}")
|
||||||
|
return {}
|
||||||
Loading…
Reference in New Issue
Block a user