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:
@@ -1,108 +1,302 @@
|
||||
"""
|
||||
Built-in tool: calendar_lookup
|
||||
|
||||
Reads calendar events from Google Calendar for a given date.
|
||||
Reads and creates Google Calendar events using per-tenant OAuth tokens.
|
||||
|
||||
Authentication options (in priority order):
|
||||
1. GOOGLE_SERVICE_ACCOUNT_KEY env var — JSON key for service account impersonation
|
||||
2. Per-tenant OAuth (future: Phase 3 portal) — not yet implemented
|
||||
3. Graceful degradation: returns informative message if not configured
|
||||
Authentication:
|
||||
Tokens are stored per-tenant in channel_connections (channel_type='google_calendar').
|
||||
The tenant admin must complete the OAuth flow via /api/portal/calendar/install first.
|
||||
If no token is found, returns an informative message asking admin to connect.
|
||||
|
||||
This tool is read-only (requires_confirmation=False in registry).
|
||||
Actions:
|
||||
- list: List events for the given date (default)
|
||||
- check_availability: Return free/busy summary for the given date
|
||||
- create: Create a new calendar event
|
||||
|
||||
Token auto-refresh:
|
||||
google.oauth2.credentials.Credentials auto-refreshes expired access tokens
|
||||
using the stored refresh_token. After each API call, if credentials.token
|
||||
changed (refresh occurred), the updated token is encrypted and written back
|
||||
to channel_connections so subsequent calls don't re-trigger refresh.
|
||||
|
||||
All results are formatted as natural language strings — no raw JSON exposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
# Module-level imports for patchability in tests.
|
||||
# google-auth and googleapiclient are optional dependencies — import errors handled
|
||||
# gracefully in the functions that use them.
|
||||
try:
|
||||
from googleapiclient.discovery import build # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
build = None # type: ignore[assignment]
|
||||
|
||||
from shared.config import settings
|
||||
from shared.crypto import KeyEncryptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Google Calendar API scope (must match what was requested during OAuth)
|
||||
_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar"
|
||||
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
|
||||
|
||||
def google_credentials_from_token(token_dict: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Build a google.oauth2.credentials.Credentials object from a stored token dict.
|
||||
|
||||
The token dict is the JSON structure written by calendar_auth.py during OAuth:
|
||||
{
|
||||
"token": "ya29.access_token",
|
||||
"refresh_token": "1//refresh_token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "...",
|
||||
"client_secret": "...",
|
||||
"scopes": ["https://www.googleapis.com/auth/calendar"]
|
||||
}
|
||||
|
||||
Args:
|
||||
token_dict: Parsed token dictionary.
|
||||
|
||||
Returns:
|
||||
google.oauth2.credentials.Credentials instance.
|
||||
|
||||
Raises:
|
||||
ImportError: If google-auth is not installed.
|
||||
"""
|
||||
from google.oauth2.credentials import Credentials # type: ignore[import-untyped]
|
||||
|
||||
return Credentials(
|
||||
token=token_dict.get("token"),
|
||||
refresh_token=token_dict.get("refresh_token"),
|
||||
token_uri=token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
|
||||
client_id=token_dict.get("client_id"),
|
||||
client_secret=token_dict.get("client_secret"),
|
||||
scopes=token_dict.get("scopes", [_CALENDAR_SCOPE]),
|
||||
)
|
||||
|
||||
|
||||
async def calendar_lookup(
|
||||
date: str,
|
||||
action: str = "list",
|
||||
event_summary: str | None = None,
|
||||
event_start: str | None = None,
|
||||
event_end: str | None = None,
|
||||
calendar_id: str = "primary",
|
||||
tenant_id: str | None = None,
|
||||
_session: Any = None, # Injected in tests; production uses DB session from task context
|
||||
**kwargs: object,
|
||||
) -> str:
|
||||
"""
|
||||
Look up calendar events for a specific date.
|
||||
Look up, check availability, or create Google Calendar events for a specific date.
|
||||
|
||||
Args:
|
||||
date: Date in YYYY-MM-DD format.
|
||||
calendar_id: Google Calendar ID. Defaults to 'primary'.
|
||||
date: Date in YYYY-MM-DD format (required).
|
||||
action: One of "list", "check_availability", "create". Default: "list".
|
||||
event_summary: Event title (required for action="create").
|
||||
event_start: ISO 8601 datetime with timezone (required for action="create").
|
||||
event_end: ISO 8601 datetime with timezone (required for action="create").
|
||||
calendar_id: Google Calendar ID. Defaults to 'primary'.
|
||||
tenant_id: Konstruct tenant UUID string. Required for token lookup.
|
||||
_session: Injected AsyncSession (for testing). Production passes None.
|
||||
|
||||
Returns:
|
||||
Formatted string listing events for the given date,
|
||||
or an informative message if Google Calendar is not configured.
|
||||
Natural language string describing the result.
|
||||
"""
|
||||
service_account_key_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_KEY", "")
|
||||
if not service_account_key_json:
|
||||
return (
|
||||
"Calendar lookup is not configured. "
|
||||
"Set the GOOGLE_SERVICE_ACCOUNT_KEY environment variable to enable calendar access."
|
||||
)
|
||||
# Guard: tenant_id is required to look up per-tenant OAuth token
|
||||
if not tenant_id:
|
||||
return "Calendar not available: missing tenant context."
|
||||
|
||||
# Get DB session
|
||||
session = _session
|
||||
if session is None:
|
||||
# Production: obtain a session from the DB pool
|
||||
# Import here to avoid circular imports at module load time
|
||||
try:
|
||||
from shared.db import async_session_factory
|
||||
session = async_session_factory()
|
||||
# Note: caller is responsible for closing the session
|
||||
# In practice, the orchestrator task context manages session lifecycle
|
||||
except Exception:
|
||||
logger.exception("Failed to create DB session for calendar_lookup")
|
||||
return "Calendar lookup failed: unable to connect to the database."
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
tenant_uuid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
return f"Calendar lookup failed: invalid tenant ID '{tenant_id}'."
|
||||
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_fetch_calendar_events_sync,
|
||||
service_account_key_json,
|
||||
calendar_id,
|
||||
date,
|
||||
# Load per-tenant OAuth token from channel_connections
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from shared.models.tenant import ChannelConnection, ChannelTypeEnum
|
||||
|
||||
result = await session.execute(
|
||||
select(ChannelConnection).where(
|
||||
ChannelConnection.tenant_id == tenant_uuid,
|
||||
ChannelConnection.channel_type == ChannelTypeEnum.GOOGLE_CALENDAR,
|
||||
)
|
||||
)
|
||||
return result
|
||||
conn = result.scalar_one_or_none()
|
||||
except Exception:
|
||||
logger.exception("Calendar lookup failed for date=%s calendar=%s", date, calendar_id)
|
||||
logger.exception("DB error loading calendar connection for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: database error loading calendar connection."
|
||||
|
||||
if conn is None:
|
||||
return (
|
||||
"Google Calendar is not connected for this tenant. "
|
||||
"Ask an admin to connect it in Settings."
|
||||
)
|
||||
|
||||
# Decrypt token
|
||||
encrypted_token = conn.config.get("token", "")
|
||||
if not encrypted_token:
|
||||
return "Calendar lookup failed: no token found in connection config."
|
||||
|
||||
try:
|
||||
if not settings.platform_encryption_key:
|
||||
return "Calendar lookup failed: encryption key not configured."
|
||||
|
||||
enc_svc = KeyEncryptionService(
|
||||
primary_key=settings.platform_encryption_key,
|
||||
previous_key=settings.platform_encryption_key_previous,
|
||||
)
|
||||
token_json: str = enc_svc.decrypt(encrypted_token)
|
||||
token_dict: dict[str, Any] = json.loads(token_json)
|
||||
except Exception:
|
||||
logger.exception("Failed to decrypt calendar token for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: unable to decrypt stored credentials."
|
||||
|
||||
# Build Google credentials
|
||||
try:
|
||||
creds = google_credentials_from_token(token_dict)
|
||||
except ImportError:
|
||||
return (
|
||||
"Google Calendar library not installed. "
|
||||
"Run: uv add google-api-python-client google-auth"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to build Google credentials for tenant=%s", tenant_id)
|
||||
return "Calendar lookup failed: invalid stored credentials."
|
||||
|
||||
# Record the token before the API call to detect refresh
|
||||
token_before = creds.token
|
||||
|
||||
# Execute the API call in a thread executor (blocking SDK)
|
||||
try:
|
||||
result_str = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_execute_calendar_action,
|
||||
creds,
|
||||
action,
|
||||
date,
|
||||
calendar_id,
|
||||
event_summary,
|
||||
event_start,
|
||||
event_end,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Calendar API call failed for tenant=%s date=%s action=%s", tenant_id, date, action)
|
||||
return f"Calendar lookup failed for {date}. Please try again."
|
||||
|
||||
# Token refresh write-back: if token changed after the API call, persist the update
|
||||
if creds.token and creds.token != token_before:
|
||||
try:
|
||||
new_token_dict = {
|
||||
"token": creds.token,
|
||||
"refresh_token": creds.refresh_token or token_dict.get("refresh_token", ""),
|
||||
"token_uri": token_dict.get("token_uri", _GOOGLE_TOKEN_URL),
|
||||
"client_id": token_dict.get("client_id", ""),
|
||||
"client_secret": token_dict.get("client_secret", ""),
|
||||
"scopes": token_dict.get("scopes", [_CALENDAR_SCOPE]),
|
||||
}
|
||||
new_encrypted = enc_svc.encrypt(json.dumps(new_token_dict))
|
||||
conn.config = {"token": new_encrypted}
|
||||
await session.commit()
|
||||
logger.debug("Calendar token refreshed and written back for tenant=%s", tenant_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to write back refreshed calendar token for tenant=%s", tenant_id)
|
||||
# Non-fatal: the API call succeeded, just log the refresh failure
|
||||
|
||||
def _fetch_calendar_events_sync(
|
||||
service_account_key_json: str,
|
||||
calendar_id: str,
|
||||
return result_str
|
||||
|
||||
|
||||
def _execute_calendar_action(
|
||||
creds: Any,
|
||||
action: str,
|
||||
date: str,
|
||||
calendar_id: str,
|
||||
event_summary: str | None,
|
||||
event_start: str | None,
|
||||
event_end: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Synchronous implementation — runs in thread executor to avoid blocking event loop.
|
||||
Synchronous calendar action — runs in thread executor to avoid blocking.
|
||||
|
||||
Uses google-api-python-client with service account credentials.
|
||||
Args:
|
||||
creds: Google Credentials object.
|
||||
action: One of "list", "check_availability", "create".
|
||||
date: Date in YYYY-MM-DD format.
|
||||
calendar_id: Google Calendar ID (default "primary").
|
||||
event_summary: Title for create action.
|
||||
event_start: ISO 8601 start for create action.
|
||||
event_end: ISO 8601 end for create action.
|
||||
|
||||
Returns:
|
||||
Natural language result string.
|
||||
"""
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError:
|
||||
if build is None:
|
||||
return (
|
||||
"Google Calendar library not installed. "
|
||||
"Run: uv add google-api-python-client google-auth"
|
||||
)
|
||||
|
||||
try:
|
||||
key_data = json.loads(service_account_key_json)
|
||||
except json.JSONDecodeError:
|
||||
return "Invalid GOOGLE_SERVICE_ACCOUNT_KEY: not valid JSON."
|
||||
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
key_data,
|
||||
scopes=["https://www.googleapis.com/auth/calendar.readonly"],
|
||||
)
|
||||
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
|
||||
except Exception as exc:
|
||||
return f"Failed to create Google credentials: {exc}"
|
||||
logger.warning("Failed to build Google Calendar service: %s", exc)
|
||||
return f"Calendar service error: {exc}"
|
||||
|
||||
# Parse the date and create RFC3339 time boundaries for the day
|
||||
if action == "create":
|
||||
return _action_create(service, calendar_id, event_summary, event_start, event_end)
|
||||
elif action == "check_availability":
|
||||
return _action_check_availability(service, calendar_id, date)
|
||||
else:
|
||||
# Default: "list"
|
||||
return _action_list(service, calendar_id, date)
|
||||
|
||||
|
||||
def _time_boundaries(date: str) -> tuple[str, str]:
|
||||
"""Return (time_min, time_max) RFC3339 strings for the full given day (UTC)."""
|
||||
return f"{date}T00:00:00Z", f"{date}T23:59:59Z"
|
||||
|
||||
|
||||
def _format_event_time(event: dict[str, Any]) -> str:
|
||||
"""Extract and format the start time of a calendar event."""
|
||||
start = event.get("start", {})
|
||||
raw = start.get("dateTime") or start.get("date") or "Unknown time"
|
||||
# Trim the timezone part for readability if full datetime
|
||||
if "T" in raw:
|
||||
try:
|
||||
# e.g. "2026-03-26T09:00:00+00:00" → "09:00"
|
||||
time_part = raw.split("T")[1][:5]
|
||||
return time_part
|
||||
except IndexError:
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def _action_list(service: Any, calendar_id: str, date: str) -> str:
|
||||
"""List calendar events for the given date."""
|
||||
time_min, time_max = _time_boundaries(date)
|
||||
try:
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return f"Invalid date format: {date!r}. Expected YYYY-MM-DD."
|
||||
|
||||
time_min = date_obj.strftime("%Y-%m-%dT00:00:00Z")
|
||||
time_max = date_obj.strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
try:
|
||||
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
|
||||
events_result = (
|
||||
service.events()
|
||||
.list(
|
||||
@@ -115,17 +309,89 @@ def _fetch_calendar_events_sync(
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar API error: %s", exc)
|
||||
return f"Calendar API error: {exc}"
|
||||
logger.warning("Google Calendar list error: %s", exc)
|
||||
return f"Calendar error listing events for {date}: {exc}"
|
||||
|
||||
items = events_result.get("items", [])
|
||||
if not items:
|
||||
return f"No events found on {date}."
|
||||
|
||||
lines = [f"Calendar events for {date}:\n"]
|
||||
lines = [f"Calendar events for {date}:"]
|
||||
for event in items:
|
||||
start = event["start"].get("dateTime", event["start"].get("date", "Unknown time"))
|
||||
time_str = _format_event_time(event)
|
||||
summary = event.get("summary", "Untitled event")
|
||||
lines.append(f"- {start}: {summary}")
|
||||
lines.append(f"- {time_str}: {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _action_check_availability(service: Any, calendar_id: str, date: str) -> str:
|
||||
"""Return a free/busy summary for the given date."""
|
||||
time_min, time_max = _time_boundaries(date)
|
||||
try:
|
||||
events_result = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar availability check error: %s", exc)
|
||||
return f"Calendar error checking availability for {date}: {exc}"
|
||||
|
||||
items = events_result.get("items", [])
|
||||
if not items:
|
||||
return f"No events on {date} — the entire day is free."
|
||||
|
||||
lines = [f"Busy slots on {date}:"]
|
||||
for event in items:
|
||||
time_str = _format_event_time(event)
|
||||
summary = event.get("summary", "Untitled event")
|
||||
lines.append(f"- {time_str}: {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _action_create(
|
||||
service: Any,
|
||||
calendar_id: str,
|
||||
event_summary: str | None,
|
||||
event_start: str | None,
|
||||
event_end: str | None,
|
||||
) -> str:
|
||||
"""Create a new calendar event."""
|
||||
if not event_summary or not event_start or not event_end:
|
||||
missing = []
|
||||
if not event_summary:
|
||||
missing.append("event_summary")
|
||||
if not event_start:
|
||||
missing.append("event_start")
|
||||
if not event_end:
|
||||
missing.append("event_end")
|
||||
return f"Cannot create event: missing required fields: {', '.join(missing)}."
|
||||
|
||||
event_body = {
|
||||
"summary": event_summary,
|
||||
"start": {"dateTime": event_start},
|
||||
"end": {"dateTime": event_end},
|
||||
}
|
||||
|
||||
try:
|
||||
created = (
|
||||
service.events()
|
||||
.insert(calendarId=calendar_id, body=event_body)
|
||||
.execute()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Google Calendar create error: %s", exc)
|
||||
return f"Failed to create calendar event: {exc}"
|
||||
|
||||
summary = created.get("summary", event_summary)
|
||||
start = created.get("start", {}).get("dateTime", event_start)
|
||||
end = created.get("end", {}).get("dateTime", event_end)
|
||||
return f"Event created: {summary} from {start} to {end}."
|
||||
|
||||
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