195 lines
6.1 KiB
Python
195 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import time
|
|
import hashlib
|
|
|
|
from google.oauth2 import service_account
|
|
from google.oauth2 import id_token as google_id_token
|
|
from google.auth.transport import requests as google_requests
|
|
|
|
import httpx
|
|
import jwt
|
|
|
|
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CHAT_API_SCOPE = "https://www.googleapis.com/auth/chat.bot"
|
|
CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"
|
|
CERTS_TTL = 600
|
|
|
|
_STANDARD_CHAT_ISSUER = "chat@system.gserviceaccount.com"
|
|
_ADDON_ISSUER_PATTERN = re.compile(r"^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$")
|
|
|
|
_auth_cache: dict[str, tuple[str, service_account.Credentials]] = {}
|
|
_MAX_AUTH_CACHE = 32
|
|
|
|
_cert_cache: tuple[float, dict[str, str]] | None = None
|
|
_http_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
async def _get_http() -> httpx.AsyncClient:
|
|
global _http_client
|
|
if _http_client is None:
|
|
_http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
|
|
return _http_client
|
|
|
|
|
|
def _normalize_private_key(key: str) -> str:
|
|
return key.replace("\\n", "\n")
|
|
|
|
|
|
def _build_auth_key(sa_info: dict, sa_file: str | None) -> str:
|
|
if sa_file:
|
|
return f"file:{sa_file}"
|
|
if sa_info:
|
|
key_hash = hashlib.sha256(
|
|
json.dumps({"client_email": sa_info.get("client_email"), "project_id": sa_info.get("project_id")}).encode()
|
|
).hexdigest()[:16]
|
|
return f"inline:{key_hash}"
|
|
return "none"
|
|
|
|
|
|
def get_or_create_credentials(
|
|
account_id: str, sa_info: dict, sa_file: str | None
|
|
) -> service_account.Credentials:
|
|
key = _build_auth_key(sa_info, sa_file)
|
|
cached = _auth_cache.get(account_id)
|
|
if cached and cached[0] == key:
|
|
return cached[1]
|
|
|
|
private_key = _normalize_private_key(sa_info.get("private_key", ""))
|
|
|
|
creds = service_account.Credentials.from_service_account_info(
|
|
{
|
|
**sa_info,
|
|
"private_key": private_key,
|
|
},
|
|
scopes=[CHAT_API_SCOPE],
|
|
)
|
|
|
|
if len(_auth_cache) >= _MAX_AUTH_CACHE:
|
|
oldest = next(iter(_auth_cache))
|
|
_auth_cache.pop(oldest)
|
|
|
|
_auth_cache[account_id] = (key, creds)
|
|
return creds
|
|
|
|
|
|
async def get_access_token(account: ResolvedGoogleChatAccount) -> str | None:
|
|
if not account.service_account_info:
|
|
return None
|
|
creds = get_or_create_credentials(
|
|
account.account_id,
|
|
account.service_account_info,
|
|
account.service_account_file,
|
|
)
|
|
request = google_requests.Request()
|
|
creds.refresh(request)
|
|
return creds.token
|
|
|
|
|
|
def decode_unverified_jwt_payload(token: str) -> dict:
|
|
import base64
|
|
|
|
parts = token.split(".")
|
|
if len(parts) < 2:
|
|
return {}
|
|
try:
|
|
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
|
decoded = base64.b64decode(padded.replace("-", "+").replace("_", "/"), validate=False)
|
|
return json.loads(decoded)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
async def _google_verify_id_token(token: str, audience: str) -> dict:
|
|
request = google_requests.Request()
|
|
return google_id_token.verify_oauth2_token(token, request, audience)
|
|
|
|
|
|
async def _fetch_chat_certs() -> dict[str, str]:
|
|
global _cert_cache
|
|
now = time.monotonic()
|
|
if _cert_cache and now - _cert_cache[0] < CERTS_TTL:
|
|
return _cert_cache[1]
|
|
|
|
http = await _get_http()
|
|
resp = await http.get(CERTS_URL)
|
|
resp.raise_for_status()
|
|
certs = resp.json()
|
|
_cert_cache = (now, certs)
|
|
return certs
|
|
|
|
|
|
async def _verify_signed_jwt_with_certs(token: str, audience: str, certs: dict[str, str]) -> dict:
|
|
header = jwt.get_unverified_header(token)
|
|
kid = header.get("kid", "")
|
|
|
|
cert = certs.get(kid)
|
|
if not cert:
|
|
raise jwt.InvalidTokenError(f"No matching cert for kid: {kid}")
|
|
|
|
return jwt.decode(token, cert, algorithms=["RS256"], audience=audience)
|
|
|
|
|
|
async def verify_webhook_token(
|
|
token: str, audience_type: str, audience: str, app_principal: str | None
|
|
) -> tuple[bool, dict, str | None]:
|
|
payload = decode_unverified_jwt_payload(token)
|
|
iss = payload.get("iss", "")
|
|
|
|
if iss == _STANDARD_CHAT_ISSUER:
|
|
try:
|
|
if audience_type == "app-url":
|
|
verified = await _google_verify_id_token(token, audience)
|
|
elif audience_type == "project-number":
|
|
certs = await _fetch_chat_certs()
|
|
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
|
|
else:
|
|
return False, payload, f"unsupported audience_type: {audience_type}"
|
|
return True, verified, None
|
|
except Exception as e:
|
|
return False, payload, f"token verification failed: {e}"
|
|
|
|
if _ADDON_ISSUER_PATTERN.match(iss):
|
|
if app_principal and payload.get("sub") != app_principal:
|
|
return False, payload, "token sub does not match appPrincipal"
|
|
try:
|
|
certs = await _fetch_chat_certs()
|
|
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
|
|
return True, verified, None
|
|
except Exception as e:
|
|
return False, payload, f"add-on token verification failed: {e}"
|
|
|
|
return False, payload, f"invalid issuer: {iss}"
|
|
|
|
|
|
async def probe_account(account: ResolvedGoogleChatAccount) -> dict:
|
|
token = await get_access_token(account)
|
|
if not token:
|
|
return {"ok": False, "status": "no_token", "error": "Failed to obtain access token"}
|
|
|
|
http = await _get_http()
|
|
try:
|
|
resp = await http.get(
|
|
"https://chat.googleapis.com/v1/spaces?pageSize=1",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10.0,
|
|
)
|
|
resp.raise_for_status()
|
|
return {"ok": True, "status": "connected"}
|
|
except httpx.HTTPStatusError as e:
|
|
return {"ok": False, "status": f"http_{e.response.status_code}", "error": str(e)}
|
|
except Exception as e:
|
|
return {"ok": False, "status": "error", "error": str(e)}
|
|
|
|
|
|
async def close_auth_http() -> None:
|
|
global _http_client
|
|
if _http_client:
|
|
await _http_client.aclose()
|
|
_http_client = None |