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:
310
packages/shared/shared/api/calendar_auth.py
Normal file
310
packages/shared/shared/api/calendar_auth.py
Normal 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)
|
||||
Reference in New Issue
Block a user