diff --git a/app/api/routes/scouts.py b/app/api/routes/scouts.py index 973a0d5..30e4613 100644 --- a/app/api/routes/scouts.py +++ b/app/api/routes/scouts.py @@ -7,28 +7,40 @@ Backend responsibilities are intentionally minimal: Scout configuration is owned by the Electron app and is not persisted in backend scout-config tables. + +Gmail OAuth setup (scout-specific consent): + GET /scouts/oauth/gmail/authorize — returns consent-screen URL + GET /scouts/oauth/gmail/web-callback — bounces to deep link (excluded from schema) + POST /scouts/oauth/gmail/callback — exchanges code, stores encrypted token """ from __future__ import annotations import asyncio import logging +import secrets +import time +import urllib.parse import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import RedirectResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel from app.api.deps import get_current_user +from app.auth.oauth_providers import generate_pkce_pair from app.billing.tier_manager import FEATURES +from app.config.settings import settings from app.core.scout_runner import is_agent_running, run_local_agent from app.core.device_manager import device_manager from app.core.note_summarizer import generate_note_summary from app.db import get_session -from app.models import ScoutRunLog, LocalScoutConfig +from app.integrations import encrypt_token +from app.models import CloudScoutConfig, ScoutRunLog, LocalScoutConfig from app.schemas import ( ScoutCatalogItem, ScoutCreationCheckRequest, @@ -255,3 +267,174 @@ async def summarize_note( """Generate an AI summary for a note. Used by the Electron backfill on startup.""" summary = await generate_note_summary(body.title, body.content) return NoteSummarizeResponse(summary=summary) + + +# ── Gmail OAuth setup (scout-specific) ─────────────────────────────────────── + +# Scopes required for Gmail scout connectivity. +_GMAIL_SCOUT_SCOPES = [ + "openid", + "email", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify", +] + +# Google OAuth endpoints. +_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" + +# In-memory pending OAuth states for scout Gmail consent: +# state → (code_verifier, scout_id, user_id, expires_at_epoch_s) +# Production note: replace with Redis for multi-process deployments. +_pending_scout_oauth_states: dict[str, tuple[str, str, str, float]] = {} +_SCOUT_OAUTH_TTL_SECONDS = 600 # 10 minutes + + +def _scout_gmail_redirect_uri() -> str: + """Derive the scout Gmail web-callback URI from the configured base OAUTH_REDIRECT_URI. + + ``OAUTH_REDIRECT_URI`` is the full path used for login OAuth + (e.g. http://localhost:8000/api/v1/auth/oauth/google/web-callback). + We strip the path to get the scheme+host base, then append the scout path. + """ + parsed = urllib.parse.urlparse(settings.OAUTH_REDIRECT_URI) + base = f"{parsed.scheme}://{parsed.netloc}" + return f"{base}/api/v1/scouts/oauth/gmail/web-callback" + + +class _ScoutGmailAuthorizeResponse(BaseModel): + authorize_url: str + + +class _ScoutGmailCallbackBody(BaseModel): + code: str + state: str + + +@router.get("/oauth/gmail/authorize", response_model=_ScoutGmailAuthorizeResponse) +async def scout_gmail_oauth_authorize( + scout_id: str, + current_user: UserProfile = Depends(get_current_user), +) -> _ScoutGmailAuthorizeResponse: + """Start the Gmail OAuth flow for a specific cloud scout. + + Returns the Google consent-screen URL. The client opens this URL in the + system browser; after consent Google redirects to web-callback which bounces + to the ``adiuvai://scout/oauth/gmail/callback`` deep link. + """ + if not settings.GOOGLE_AUTH_CLIENT_ID or not settings.GOOGLE_AUTH_CLIENT_SECRET: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Google OAuth is not configured on this server", + ) + + code_verifier, code_challenge = generate_pkce_pair() + state = secrets.token_urlsafe(32) + + # Purge expired states to prevent unbounded growth. + now = time.time() + expired = [s for s, (_, _, _, exp) in _pending_scout_oauth_states.items() if exp < now] + for s in expired: + del _pending_scout_oauth_states[s] + + _pending_scout_oauth_states[state] = (code_verifier, scout_id, current_user.id, now + _SCOUT_OAUTH_TTL_SECONDS) + + redirect_uri = _scout_gmail_redirect_uri() + params = { + "client_id": settings.GOOGLE_AUTH_CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(_GMAIL_SCOUT_SCOPES), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "access_type": "offline", + "prompt": "consent", + } + authorize_url = f"{_GOOGLE_AUTH_URL}?{urllib.parse.urlencode(params)}" + return _ScoutGmailAuthorizeResponse(authorize_url=authorize_url) + + +@router.get("/oauth/gmail/web-callback", include_in_schema=False) +async def scout_gmail_oauth_web_callback(code: str, state: str) -> RedirectResponse: + """Google redirects here after Gmail consent. + + Immediately bounces to the Electron deep link so the desktop app + receives the authorization code. + """ + params = urllib.parse.urlencode({"code": code, "state": state}) + deep_link = f"adiuvai://scout/oauth/gmail/callback?{params}" + return RedirectResponse(url=deep_link, status_code=302) + + +@router.post("/oauth/gmail/callback") +async def scout_gmail_oauth_callback( + body: _ScoutGmailCallbackBody, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +) -> dict: + """Exchange the Gmail authorization code and store the encrypted token on the scout. + + Called by the Electron app after it receives the deep-link callback with + the ``code`` and ``state`` params. + """ + entry = _pending_scout_oauth_states.pop(body.state, None) + if entry is None or entry[3] < time.time() or entry[2] != current_user.id: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired OAuth state") + + code_verifier, scout_id, _, _ = entry + + redirect_uri = _scout_gmail_redirect_uri() + + import httpx + async with httpx.AsyncClient() as client: + response = await client.post( + _GOOGLE_TOKEN_URL, + data={ + "client_id": settings.GOOGLE_AUTH_CLIENT_ID, + "client_secret": settings.GOOGLE_AUTH_CLIENT_SECRET, + "code": body.code, + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + }, + ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.error("Gmail token exchange failed: %s", exc.response.text) + raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Failed to exchange Gmail authorization code") + + token_data = response.json() + + creds_dict: dict = { + "token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), + "token_uri": _GOOGLE_TOKEN_URL, + "client_id": settings.GOOGLE_AUTH_CLIENT_ID, + "client_secret": settings.GOOGLE_AUTH_CLIENT_SECRET, + "scopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify", + ], + } + encrypted = encrypt_token(creds_dict) + + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + scout.oauth_token_encrypted = encrypted + await db.commit() + + # Attempt to set up Gmail push watch so we start receiving Pub/Sub notifications. + from app.scouts.connectors.registry import get_connector + try: + connector = get_connector("gmail") + await connector.setup_watch(scout) + await db.commit() + except KeyError: + logger.warning("gmail connector not registered — skipping setup_watch for scout %s", scout_id) + except Exception: + logger.exception("setup_watch failed for scout %s", scout_id) + + return {"ok": True}