Files
PolyWeather/src/auth/supabase_entitlement.py
T

508 lines
19 KiB
Python

from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
import requests
from loguru import logger
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except Exception:
return default
def extract_bearer_token(auth_header: Optional[str]) -> str:
if not auth_header:
return ""
parts = str(auth_header).strip().split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return ""
@dataclass
class SupabaseIdentity:
user_id: str
email: str
points: int = 0
created_at: Optional[str] = None
class SupabaseEntitlementService:
"""
Supabase-backed authentication and entitlement checks.
- Auth validation: /auth/v1/user with user access token.
- Entitlement check: /rest/v1/subscriptions with service role key.
"""
def __init__(self):
self.enabled = _env_bool("POLYWEATHER_AUTH_ENABLED", False)
self.require_subscription = _env_bool(
"POLYWEATHER_AUTH_REQUIRE_SUBSCRIPTION",
False,
)
self.supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
self.anon_key = str(os.getenv("SUPABASE_ANON_KEY") or "").strip()
self.service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8))
self.cache_ttl_sec = max(5, _env_int("SUPABASE_AUTH_CACHE_TTL_SEC", 30))
self.sub_cache_ttl_sec = max(5, _env_int("SUPABASE_SUB_CACHE_TTL_SEC", 60))
self.signup_trial_enabled = _env_bool(
"POLYWEATHER_SIGNUP_TRIAL_ENABLED",
True,
)
self.signup_trial_days = max(
0,
_env_int("POLYWEATHER_SIGNUP_TRIAL_DAYS", 3),
)
self.signup_trial_plan_code = str(
os.getenv("POLYWEATHER_SIGNUP_TRIAL_PLAN_CODE") or "signup_trial_3d"
).strip() or "signup_trial_3d"
self.signup_trial_source = str(
os.getenv("POLYWEATHER_SIGNUP_TRIAL_SOURCE") or "signup_trial"
).strip() or "signup_trial"
self._identity_cache: Dict[str, Dict[str, object]] = {}
self._identity_cache_lock = threading.Lock()
self._sub_cache: Dict[str, Dict[str, object]] = {}
self._sub_cache_lock = threading.Lock()
self._trial_locks: Dict[str, threading.Lock] = {}
self._trial_locks_guard = threading.Lock()
def invalidate_subscription_cache(self, user_id: str) -> None:
key = str(user_id or "").strip()
if not key:
return
with self._sub_cache_lock:
self._sub_cache.pop(key, None)
@property
def configured(self) -> bool:
return bool(self.supabase_url and self.anon_key)
def _user_endpoint(self) -> str:
return f"{self.supabase_url}/auth/v1/user"
def _subscription_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/subscriptions"
def _entitlement_events_endpoint(self) -> str:
return f"{self.supabase_url}/rest/v1/entitlement_events"
def _request_headers_for_user(self, access_token: str) -> Dict[str, str]:
return {
"apikey": self.anon_key,
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
def _request_headers_for_service_role(self) -> Dict[str, str]:
return {
"apikey": self.service_role_key,
"Authorization": f"Bearer {self.service_role_key}",
"Accept": "application/json",
}
def _admin_user_endpoint(self, user_id: str) -> str:
return f"{self.supabase_url}/auth/v1/admin/users/{user_id}"
def get_identity(self, access_token: str) -> Optional[SupabaseIdentity]:
if not access_token:
return None
now_ts = time.time()
with self._identity_cache_lock:
cached = self._identity_cache.get(access_token)
if cached and now_ts - float(cached.get("ts") or 0) < self.cache_ttl_sec:
identity = cached.get("identity")
if isinstance(identity, SupabaseIdentity):
return identity
if not self.configured:
return None
try:
response = requests.get(
self._user_endpoint(),
headers=self._request_headers_for_user(access_token),
timeout=self.timeout_sec,
)
if response.status_code != 200:
return None
data = response.json() if response.content else {}
user_id = str(data.get("id") or "").strip()
if not user_id:
return None
# Extract points from user_metadata
metadata = data.get("user_metadata") or {}
points = int(metadata.get("points") or metadata.get("total_points") or 0)
identity = SupabaseIdentity(
user_id=user_id,
email=str(data.get("email") or "").strip(),
points=points,
created_at=str(data.get("created_at") or "").strip() or None,
)
with self._identity_cache_lock:
self._identity_cache[access_token] = {
"identity": identity,
"ts": now_ts,
}
return identity
except Exception as exc:
logger.warning(f"supabase auth user check failed: {exc}")
return None
except Exception as exc:
logger.warning(f"supabase auth user check failed: {exc}")
return None
def _query_latest_active_subscription(
self,
user_id: str,
) -> Optional[Dict[str, object]]:
if not user_id:
return None
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return None
now_ts = time.time()
with self._sub_cache_lock:
cached = self._sub_cache.get(user_id)
if cached and now_ts - float(cached.get("ts") or 0) < self.sub_cache_ttl_sec:
row = cached.get("row")
if isinstance(row, dict):
return row
return None
try:
now_iso = datetime.now(timezone.utc).isoformat()
params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at",
"user_id": f"eq.{user_id}",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.desc",
"limit": "1",
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase subscription query failed user_id={} status={}",
user_id,
response.status_code,
)
row = None
else:
data = response.json() if response.content else []
row = data[0] if isinstance(data, list) and data else None
if not isinstance(row, dict):
row = None
with self._sub_cache_lock:
self._sub_cache[user_id] = {
"active": bool(row),
"row": row,
"ts": now_ts,
}
return row
except Exception as exc:
logger.warning(f"supabase subscription query error user_id={user_id}: {exc}")
return None
def _query_latest_subscription_any_status(
self,
user_id: str,
) -> Optional[Dict[str, object]]:
if not user_id or not self.service_role_key:
return None
try:
params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at,source,created_at,updated_at",
"user_id": f"eq.{user_id}",
"order": "created_at.desc",
"limit": "1",
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase subscription history query failed user_id={} status={}",
user_id,
response.status_code,
)
return None
data = response.json() if response.content else []
row = data[0] if isinstance(data, list) and data else None
return row if isinstance(row, dict) else None
except Exception as exc:
logger.warning(f"supabase subscription history query error user_id={user_id}: {exc}")
return None
@staticmethod
def _parse_iso_datetime(raw: Optional[str]) -> Optional[datetime]:
text = str(raw or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _get_trial_lock(self, user_id: str) -> threading.Lock:
key = str(user_id or "").strip()
with self._trial_locks_guard:
lock = self._trial_locks.get(key)
if lock is None:
lock = threading.Lock()
self._trial_locks[key] = lock
return lock
def _emit_signup_trial_event(
self,
*,
user_id: str,
starts_at: datetime,
expires_at: datetime,
) -> None:
if not self.service_role_key:
return
try:
now_iso = datetime.now(timezone.utc).isoformat()
requests.post(
self._entitlement_events_endpoint(),
headers={
**self._request_headers_for_service_role(),
"Content-Type": "application/json",
"Prefer": "return=minimal",
},
json={
"user_id": user_id,
"action": "subscription_granted",
"reason": "signup_trial",
"detail": f"{self.signup_trial_days}d signup trial granted",
"payload": {
"plan_code": self.signup_trial_plan_code,
"source": self.signup_trial_source,
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
},
"created_at": now_iso,
},
timeout=self.timeout_sec,
)
except Exception as exc:
logger.warning(f"supabase signup trial event insert failed user_id={user_id}: {exc}")
def ensure_signup_trial(
self,
user_id: str,
*,
created_at: Optional[str] = None,
) -> Optional[Dict[str, object]]:
normalized_user_id = str(user_id or "").strip()
if not normalized_user_id:
return None
if (
not self.signup_trial_enabled
or self.signup_trial_days <= 0
or not self.service_role_key
):
return None
lock = self._get_trial_lock(normalized_user_id)
with lock:
existing_active = self._query_latest_active_subscription(normalized_user_id)
if isinstance(existing_active, dict):
return existing_active
existing_any = self._query_latest_subscription_any_status(normalized_user_id)
if isinstance(existing_any, dict):
return None
starts_at = self._parse_iso_datetime(created_at)
if starts_at is None:
admin_users = self.get_auth_users([normalized_user_id])
starts_at = self._parse_iso_datetime(
str((admin_users.get(normalized_user_id) or {}).get("created_at") or "")
)
if starts_at is None:
return None
expires_at = starts_at + timedelta(days=self.signup_trial_days)
now = datetime.now(timezone.utc)
if expires_at <= now:
return None
payload = {
"user_id": normalized_user_id,
"plan_code": self.signup_trial_plan_code,
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": self.signup_trial_source,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
}
try:
response = requests.post(
self._subscription_endpoint(),
headers={
**self._request_headers_for_service_role(),
"Content-Type": "application/json",
"Prefer": "return=representation",
},
json=payload,
timeout=self.timeout_sec,
)
if response.status_code not in (200, 201):
logger.warning(
"supabase signup trial insert failed user_id={} status={}",
normalized_user_id,
response.status_code,
)
return self._query_latest_active_subscription(normalized_user_id)
rows = response.json() if response.content else []
row = rows[0] if isinstance(rows, list) and rows else None
self.invalidate_subscription_cache(normalized_user_id)
self._emit_signup_trial_event(
user_id=normalized_user_id,
starts_at=starts_at,
expires_at=expires_at,
)
if isinstance(row, dict):
return row
return self._query_latest_active_subscription(normalized_user_id)
except Exception as exc:
logger.warning(f"supabase signup trial insert error user_id={normalized_user_id}: {exc}")
return self._query_latest_active_subscription(normalized_user_id)
def _query_active_subscription(self, user_id: str) -> bool:
return self._query_latest_active_subscription(user_id) is not None
def get_latest_active_subscription(
self,
user_id: str,
respect_requirement: bool = True,
) -> Optional[Dict[str, object]]:
if respect_requirement and not self.require_subscription:
return None
return self._query_latest_active_subscription(user_id)
def has_active_subscription(
self,
user_id: str,
respect_requirement: bool = True,
) -> bool:
if respect_requirement and not self.require_subscription:
return True
return self._query_active_subscription(user_id)
def list_active_subscriptions(self, limit: int = 200) -> List[Dict[str, object]]:
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return []
try:
safe_limit = max(1, min(int(limit or 200), 1000))
now_iso = datetime.now(timezone.utc).isoformat()
params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.asc",
"limit": str(safe_limit),
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase active subscriptions query failed status={}",
response.status_code,
)
return []
data = response.json() if response.content else []
if not isinstance(data, list):
return []
return [row for row in data if isinstance(row, dict)]
except Exception as exc:
logger.warning(f"supabase active subscriptions query error: {exc}")
return []
def get_auth_users(self, user_ids: List[str]) -> Dict[str, Dict[str, object]]:
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return {}
keys = []
for item in user_ids or []:
key = str(item or "").strip().lower()
if key and key not in keys:
keys.append(key)
if not keys:
return {}
out: Dict[str, Dict[str, object]] = {}
for user_id in keys:
try:
response = requests.get(
self._admin_user_endpoint(user_id),
headers=self._request_headers_for_service_role(),
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase admin user query failed user_id={} status={}",
user_id,
response.status_code,
)
continue
raw = response.json() if response.content else {}
payload = raw.get("user") if isinstance(raw, dict) and isinstance(raw.get("user"), dict) else raw
if not isinstance(payload, dict):
continue
out[user_id] = {
"email": str(payload.get("email") or "").strip(),
"created_at": payload.get("created_at"),
}
except Exception as exc:
logger.warning(f"supabase admin user query error user_id={user_id}: {exc}")
return out
SUPABASE_ENTITLEMENT = SupabaseEntitlementService()