feat(10-02): Google Calendar OAuth endpoints and per-tenant calendar tool

- Add calendar_auth.py: OAuth install/callback/status endpoints with HMAC-signed state
- Replace calendar_lookup.py service account stub with per-tenant OAuth token lookup
- Support list, check_availability, and create actions with natural language responses
- Token auto-refresh: write updated credentials back to channel_connections on refresh
- Add migration 013: add google_calendar to channel_type CHECK constraint
- Add unit tests: 16 tests covering all actions, not-connected path, token refresh write-back
This commit is contained in:
2026-03-26 09:07:37 -06:00
parent e8d3e8a108
commit 08572fcc40
5 changed files with 1318 additions and 62 deletions

View File

@@ -0,0 +1,310 @@
"""
Google Calendar OAuth API endpoints — per-tenant OAuth install + callback.
Endpoints:
GET /api/portal/calendar/install?tenant_id={id}
→ generates HMAC-signed state, returns Google OAuth URL
GET /api/portal/calendar/callback?code={code}&state={state}
→ verifies state, exchanges code for tokens, stores encrypted in channel_connections
GET /api/portal/calendar/{tenant_id}/status
→ returns {"connected": bool}
OAuth state uses the same HMAC-SHA256 signed state pattern as Slack OAuth
(see shared.api.channels.generate_oauth_state / verify_oauth_state).
Token storage:
Token JSON is encrypted with the platform KeyEncryptionService (Fernet) and
stored in channel_connections with channel_type='google_calendar'.
workspace_id is set to str(tenant_id) — Google Calendar is per-tenant,
not per-workspace, so the tenant UUID serves as the workspace identifier.
Token auto-refresh:
The calendar_lookup tool handles refresh via google-auth library.
This module is responsible for initial OAuth install and status checks only.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.api.channels import generate_oauth_state, verify_oauth_state
from shared.api.rbac import PortalCaller, require_tenant_admin, require_tenant_member
from shared.config import settings
from shared.crypto import KeyEncryptionService
from shared.db import get_session
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
calendar_auth_router = APIRouter(prefix="/api/portal/calendar", tags=["calendar"])
# Google Calendar OAuth scopes — full read+write (locked decision: operators need CRUD)
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
# Google OAuth endpoints
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
# ---------------------------------------------------------------------------
# Helper: build OAuth URL
# ---------------------------------------------------------------------------
def build_calendar_oauth_url(tenant_id: str, secret: str) -> str:
"""
Build a Google OAuth 2.0 authorization URL for Calendar access.
Args:
tenant_id: Tenant UUID as string — embedded in the HMAC-signed state.
secret: HMAC secret for state generation (oauth_state_secret).
Returns:
Full Google OAuth authorization URL ready to redirect the user to.
"""
state = generate_oauth_state(tenant_id=tenant_id, secret=secret)
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={_CALENDAR_SCOPE}"
f"&access_type=offline"
f"&prompt=consent"
f"&state={state}"
)
return f"{_GOOGLE_AUTH_URL}{params}"
def _get_encryption_service() -> KeyEncryptionService:
"""Return the platform-level KeyEncryptionService."""
if not settings.platform_encryption_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PLATFORM_ENCRYPTION_KEY not configured",
)
return KeyEncryptionService(
primary_key=settings.platform_encryption_key,
previous_key=settings.platform_encryption_key_previous,
)
# ---------------------------------------------------------------------------
# Endpoint: GET /install
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/install")
async def calendar_install(
tenant_id: uuid.UUID = Query(...),
caller: PortalCaller = Depends(require_tenant_admin),
) -> dict[str, str]:
"""
Generate the Google Calendar OAuth authorization URL.
Returns a JSON object with a 'url' key. The operator's browser should
be redirected to this URL to begin the Google OAuth consent flow.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
if not settings.google_client_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="GOOGLE_CLIENT_ID not configured",
)
url = build_calendar_oauth_url(
tenant_id=str(tenant_id),
secret=settings.oauth_state_secret,
)
return {"url": url}
# ---------------------------------------------------------------------------
# Callback handler (shared between endpoint and tests)
# ---------------------------------------------------------------------------
async def handle_calendar_callback(
code: str,
state: str,
session: AsyncSession,
) -> str:
"""
Process the Google OAuth callback: verify state, exchange code, store token.
Args:
code: Authorization code from Google.
state: HMAC-signed state parameter.
session: Async DB session for storing the ChannelConnection.
Returns:
Redirect URL string (portal /settings?calendar=connected).
Raises:
HTTPException 400 if state is invalid or token exchange fails.
"""
if not settings.oauth_state_secret:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAUTH_STATE_SECRET not configured",
)
# Verify HMAC state to recover tenant_id
try:
tenant_id_str = verify_oauth_state(state=state, secret=settings.oauth_state_secret)
tenant_id = uuid.UUID(tenant_id_str)
except (ValueError, Exception) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid OAuth state: {exc}",
) from exc
redirect_uri = f"{settings.portal_url}/api/portal/calendar/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
_GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Google token exchange failed",
)
token_data: dict[str, Any] = response.json()
if "error" in token_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Google OAuth error: {token_data.get('error_description', token_data['error'])}",
)
# Build token JSON for storage (google-auth credentials format)
token_json = json.dumps({
"token": token_data.get("access_token", ""),
"refresh_token": token_data.get("refresh_token", ""),
"token_uri": _GOOGLE_TOKEN_URL,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"scopes": [_CALENDAR_SCOPE],
})
# Encrypt before storage
enc_svc = _get_encryption_service()
encrypted_token = enc_svc.encrypt(token_json)
# Upsert ChannelConnection for google_calendar
existing = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = existing.scalar_one_or_none()
if conn is None:
conn = ChannelConnection(
tenant_id=tenant_id,
channel_type=ChannelTypeEnum.GOOGLE_CALENDAR,
workspace_id=str(tenant_id), # tenant UUID as workspace_id
config={"token": encrypted_token},
)
session.add(conn)
else:
conn.config = {"token": encrypted_token}
await session.commit()
return f"{settings.portal_url}/settings?calendar=connected"
# ---------------------------------------------------------------------------
# Endpoint: GET /callback
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/callback")
async def calendar_callback(
code: str = Query(...),
state: str = Query(...),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""
Handle the Google Calendar OAuth callback from Google.
No auth guard — this endpoint receives an external redirect from Google
(no session cookie available during OAuth flow).
Verifies HMAC state, exchanges code for tokens, stores encrypted token,
then redirects to portal /settings?calendar=connected.
"""
redirect_url = await handle_calendar_callback(code=code, state=state, session=session)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
# ---------------------------------------------------------------------------
# Status check helper (for tests)
# ---------------------------------------------------------------------------
async def get_calendar_status(
tenant_id: uuid.UUID,
session: AsyncSession,
) -> dict[str, bool]:
"""
Check if a Google Calendar connection exists for a tenant.
Args:
tenant_id: Tenant UUID to check.
session: Async DB session.
Returns:
{"connected": True} if a ChannelConnection exists, {"connected": False} otherwise.
"""
result = await session.execute(
select(ChannelConnection).where(
ChannelConnection.tenant_id == tenant_id,
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
)
)
conn = result.scalar_one_or_none()
return {"connected": conn is not None}
# ---------------------------------------------------------------------------
# Endpoint: GET /{tenant_id}/status
# ---------------------------------------------------------------------------
@calendar_auth_router.get("/{tenant_id}/status")
async def calendar_status(
tenant_id: uuid.UUID,
caller: PortalCaller = Depends(require_tenant_member),
session: AsyncSession = Depends(get_session),
) -> dict[str, bool]:
"""
Check if Google Calendar is connected for a tenant.
Returns {"connected": true} if the tenant has authorized Google Calendar,
{"connected": false} otherwise.
"""
return await get_calendar_status(tenant_id=tenant_id, session=session)