import logging from datetime import datetime, timezone import httpx from google.auth.transport.requests import Request as GoogleAuthRequest from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import Flow from sqlalchemy.orm import Session from app.config import settings from app.core.crypto import decrypt_token, encrypt_token from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials logger = logging.getLogger(__name__) SCOPES = [ "https://www.googleapis.com/auth/youtube.readonly", "openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", ] AUTH_URI = "https://accounts.google.com/o/oauth2/auth" TOKEN_URI = "https://oauth2.googleapis.com/token" USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo" REVOKE_URI = "https://oauth2.googleapis.com/revoke" class OAuthNotConnected(Exception): pass def _client_config() -> dict: return { "web": { "client_id": settings.google_client_id, "client_secret": settings.google_client_secret, "auth_uri": AUTH_URI, "token_uri": TOKEN_URI, "redirect_uris": [settings.google_redirect_uri], } } def _build_flow(state: str | None = None) -> Flow: return Flow.from_client_config( _client_config(), scopes=SCOPES, state=state, redirect_uri=settings.google_redirect_uri, ) def build_authorization_url() -> tuple[str, str]: flow = _build_flow() auth_url, state = flow.authorization_url( access_type="offline", prompt="consent", include_granted_scopes="true", ) return auth_url, state def exchange_code(code: str, state: str) -> Credentials: flow = _build_flow(state=state) flow.fetch_token(code=code) return flow.credentials def fetch_userinfo(access_token: str) -> dict: response = httpx.get( USERINFO_URI, headers={"Authorization": f"Bearer {access_token}"}, timeout=settings.metube_request_timeout_seconds, ) response.raise_for_status() return response.json() def revoke_token(token: str) -> None: try: httpx.post(REVOKE_URI, params={"token": token}, timeout=10) except Exception: logger.warning("Failed to revoke Google token", exc_info=True) def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None: encrypted = encrypt_token(credentials.refresh_token) expires_at = credentials.expiry if expires_at is not None and expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) row = db.get(OAuthCredentials, SINGLETON_ID) if row is None: row = OAuthCredentials(id=SINGLETON_ID, google_email=google_email, encrypted_refresh_token=encrypted) db.add(row) else: row.google_email = google_email row.encrypted_refresh_token = encrypted row.access_token_expires_at = expires_at db.commit() def clear_credentials(db: Session) -> None: row = db.get(OAuthCredentials, SINGLETON_ID) if row is not None: db.delete(row) db.commit() def is_connected(db: Session) -> bool: return db.get(OAuthCredentials, SINGLETON_ID) is not None def get_connected_email(db: Session) -> str | None: row = db.get(OAuthCredentials, SINGLETON_ID) return row.google_email if row else None def get_credentials(db: Session) -> Credentials: row = db.get(OAuthCredentials, SINGLETON_ID) if row is None: raise OAuthNotConnected("Google account is not connected") refresh_token = decrypt_token(row.encrypted_refresh_token) credentials = Credentials( token=None, refresh_token=refresh_token, token_uri=TOKEN_URI, client_id=settings.google_client_id, client_secret=settings.google_client_secret, scopes=SCOPES, ) credentials.refresh(GoogleAuthRequest()) expires_at = credentials.expiry if expires_at is not None and expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=timezone.utc) row.access_token_expires_at = expires_at db.commit() return credentials