feat(scouts): add Gmail OAuth scout-setup routes
Three new endpoints under /api/v1/scouts/oauth/gmail/: GET /authorize — PKCE consent URL for gmail.readonly + gmail.modify scopes GET /web-callback — bounces to adiuvai:// deep link (excluded from schema) POST /callback — exchanges code, encrypts + stores token, triggers setup_watch State TTL 10 min, in-memory (same pattern as auth.py _pending_states). Redirect URI base derived from existing OAUTH_REDIRECT_URI setting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,28 +7,40 @@ Backend responsibilities are intentionally minimal:
|
|||||||
|
|
||||||
Scout configuration is owned by the Electron app and is not persisted
|
Scout configuration is owned by the Electron app and is not persisted
|
||||||
in backend scout-config tables.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
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.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.scout_runner import is_agent_running, run_local_agent
|
||||||
from app.core.device_manager import device_manager
|
from app.core.device_manager import device_manager
|
||||||
from app.core.note_summarizer import generate_note_summary
|
from app.core.note_summarizer import generate_note_summary
|
||||||
from app.db import get_session
|
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 (
|
from app.schemas import (
|
||||||
ScoutCatalogItem,
|
ScoutCatalogItem,
|
||||||
ScoutCreationCheckRequest,
|
ScoutCreationCheckRequest,
|
||||||
@@ -255,3 +267,174 @@ async def summarize_note(
|
|||||||
"""Generate an AI summary for a note. Used by the Electron backfill on startup."""
|
"""Generate an AI summary for a note. Used by the Electron backfill on startup."""
|
||||||
summary = await generate_note_summary(body.title, body.content)
|
summary = await generate_note_summary(body.title, body.content)
|
||||||
return NoteSummarizeResponse(summary=summary)
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user