feat(scouts): gmail pub/sub webhook with JWT verification
This commit is contained in:
120
app/api/routes/scout_webhooks.py
Normal file
120
app/api/routes/scout_webhooks.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Gmail Pub/Sub push receiver.
|
||||
|
||||
Google Pub/Sub push subscriptions deliver Gmail watch notifications as POST
|
||||
requests with a JSON envelope. The body payload contains a base64-encoded
|
||||
JSON blob with ``emailAddress`` + ``historyId``. We resolve the user by
|
||||
email, look up their cloud_scout_configs row for provider='gmail', and
|
||||
hand off to ScoutEngine.trigger_scout.
|
||||
|
||||
Authentication: Pub/Sub push includes an OIDC JWT in the Authorization
|
||||
header. We verify it against Google's public keys with the audience
|
||||
configured in our Pub/Sub subscription.
|
||||
|
||||
Dev mode: when ``GMAIL_PUBSUB_AUDIENCE`` is empty, JWT verification is
|
||||
skipped and a warning is logged. Production must set this env var.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config.settings import settings
|
||||
from app.db import async_session
|
||||
from app.models import CloudScoutConfig, User
|
||||
from app.scouts.engine import ScoutEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/scouts/webhooks", tags=["scout-webhooks"])
|
||||
|
||||
|
||||
def _verify_pubsub_jwt(token: str) -> bool:
|
||||
"""Verify the Google Pub/Sub OIDC JWT.
|
||||
|
||||
Returns True when valid, False on any verification failure.
|
||||
|
||||
Dev skip: if ``settings.GMAIL_PUBSUB_AUDIENCE`` is empty, logs a
|
||||
warning and returns True so local development works without a real
|
||||
Pub/Sub subscription. Production must configure the audience.
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
|
||||
if not settings.GMAIL_PUBSUB_AUDIENCE:
|
||||
logger.warning(
|
||||
"GMAIL_PUBSUB_AUDIENCE not set — skipping Pub/Sub JWT verification (dev mode only)"
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
from google.auth.transport import requests as g_requests # noqa: PLC0415
|
||||
from google.oauth2 import id_token # noqa: PLC0415
|
||||
|
||||
id_token.verify_oauth2_token(
|
||||
token,
|
||||
g_requests.Request(),
|
||||
audience=settings.GMAIL_PUBSUB_AUDIENCE,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("pubsub jwt verification failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/gmail", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def gmail_pubsub(
|
||||
request: Request,
|
||||
authorization: str = Header(default=""),
|
||||
) -> None:
|
||||
"""Receive a Gmail Pub/Sub push notification.
|
||||
|
||||
Verifies the OIDC JWT, decodes the Pub/Sub envelope, resolves the user
|
||||
by email, and triggers ScoutEngine.trigger_scout for each enabled Gmail
|
||||
scout belonging to that user.
|
||||
|
||||
Returns 204 No Content on success (including benign no-ops like unknown
|
||||
email or empty message data). Returns 401 on JWT verification failure.
|
||||
"""
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if not _verify_pubsub_jwt(token):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid Pub/Sub JWT")
|
||||
|
||||
body = await request.json()
|
||||
msg = body.get("message") or {}
|
||||
raw = msg.get("data")
|
||||
if not raw:
|
||||
return # ack without action — empty message data
|
||||
|
||||
try:
|
||||
decoded = json.loads(base64.b64decode(raw).decode())
|
||||
except Exception:
|
||||
logger.warning("pubsub payload decode failed")
|
||||
return
|
||||
|
||||
email = decoded.get("emailAddress")
|
||||
if not email:
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
user_q = await session.execute(select(User).where(User.email == email))
|
||||
user = user_q.scalar_one_or_none()
|
||||
if user is None:
|
||||
logger.info("pubsub: no user for %s — ignoring", email)
|
||||
return
|
||||
scouts_q = await session.execute(
|
||||
select(CloudScoutConfig).where(
|
||||
CloudScoutConfig.user_id == user.id,
|
||||
CloudScoutConfig.provider == "gmail",
|
||||
CloudScoutConfig.enabled == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
scouts = scouts_q.scalars().all()
|
||||
|
||||
engine = ScoutEngine()
|
||||
for scout in scouts:
|
||||
await engine.trigger_scout(uuid.UUID(str(scout.id)))
|
||||
@@ -62,6 +62,11 @@ class Settings(BaseSettings):
|
||||
# Full resource name, e.g. "projects/my-project/topics/gmail-push".
|
||||
# Leave empty in dev — setup_watch will skip registration gracefully.
|
||||
GMAIL_PUBSUB_TOPIC: str = ""
|
||||
# OIDC token audience for Pub/Sub push subscription JWT verification.
|
||||
# Set to the service account email or audience string configured in the
|
||||
# Pub/Sub push subscription. Leave empty in dev to skip verification
|
||||
# (a warning is logged — never silent in production).
|
||||
GMAIL_PUBSUB_AUDIENCE: str = ""
|
||||
|
||||
# Fernet key (URL-safe base64, 32-byte key) for at-rest encryption of OAuth
|
||||
# tokens stored in cloud_agent_configs.oauth_token_encrypted.
|
||||
|
||||
15
app/main.py
15
app/main.py
@@ -129,14 +129,15 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(SanitizerMiddleware)
|
||||
app.add_middleware(TierRateLimitMiddleware)
|
||||
|
||||
from app.api.routes import scouts, auth, billing, chat, device_ws, memory
|
||||
from app.api.routes import scouts, auth, billing, chat, device_ws, memory, scout_webhooks
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(chat.router, prefix="/api/v1")
|
||||
app.include_router(billing.router, prefix="/api/v1")
|
||||
app.include_router(scouts.router, prefix="/api/v1")
|
||||
app.include_router(device_ws.router, prefix="/api/v1")
|
||||
app.include_router(memory.router, prefix="/api/v1")
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(chat.router, prefix="/api/v1")
|
||||
app.include_router(billing.router, prefix="/api/v1")
|
||||
app.include_router(scouts.router, prefix="/api/v1")
|
||||
app.include_router(scout_webhooks.router, prefix="/api/v1")
|
||||
app.include_router(device_ws.router, prefix="/api/v1")
|
||||
app.include_router(memory.router, prefix="/api/v1")
|
||||
|
||||
@app.get("/api/v1/health", tags=["health"])
|
||||
async def health() -> dict:
|
||||
|
||||
Reference in New Issue
Block a user