52:def resolve_client_config(config=None) -> dict: 53- """Return {client_id, client_secret, redirect_uri} for OAuth flows. 54- 55- Priority: explicit config dict (per-user imported config) > DB row > env file path > error. 56- """ 57- if config and config.client_id: 58- return { 59- "client_id": config.client_id, 60- "client_secret": config.client_secret, # decrypted property 61- "redirect_uri": config.redirect_uri or f"{settings.BASE_URL}/auth/google/callback", 62- } 63- path = settings.GOOGLE_OAUTH_CLIENT_JSON_PATH 64- if path and __import__("os").path.exists(path): 65- with open(path) as f: 66- data = json.load(f) 67- inst = data.get("installed") or data.get("web") 68- if not inst: 69- raise GmailOauthError("Invalid Google OAuth client JSON: no 'installed' or 'web' key") 70- return { 71- "client_id": inst["client_id"], 72- "client_secret": inst["client_secret"], 73- "redirect_uri": f"{settings.BASE_URL}/auth/google/callback", 74- } 75- raise GmailOauthError( 76- "No Google OAuth client configuration available. Import a client_secret.json in " 77- "Gmail Accounts → Import Gmail Configuration, or set GOOGLE_OAUTH_CLIENT_JSON_PATH." -- 81:def parse_client_json(content: bytes) -> dict: 82- """Validate an uploaded client_secret JSON. Returns normalized dict; raises on invalid.""" 83- try: 84- data = json.loads(content.decode("utf-8")) 85- except Exception as e: 86- raise GmailOauthError(f"File is not valid JSON: {e}") 87- inst = data.get("installed") or data.get("web") 88- if not inst or not inst.get("client_id") or not inst.get("client_secret"): 89- raise GmailOauthError("JSON must contain an 'installed' or 'web' object with client_id and client_secret") 90- return { 91- "project_id": inst.get("project", "") or inst.get("project_id", ""), 92- "client_id": inst["client_id"], 93- "client_secret": inst["client_secret"], 94- "redirect_uris": inst.get("redirect_uris", []), 95- "auth_uri": inst.get("auth_uri", ""), 96- "token_uri": inst.get("token_uri", ""), 97- } 98- 99- 100-# --------------------------------------------------------------------------- 101-# Authorization flow 102-# --------------------------------------------------------------------------- 103-def generate_state() -> str: 104- return secrets.token_urlsafe(32) 105- 106- -- 152:def revoke_token(token: str) -> bool: 153- """Revoke a token at Google. Returns True if revoked successfully.""" 154- if not token: 155- return False 156- try: 157- resp = requests.post(GOOGLE_REVOKE_URL, params={"token": token}, timeout=15) 158- return resp.status_code == 200 159- except Exception: 160- return False 161- 162- 163-def get_userinfo(access_token: str) -> dict: 164- resp = requests.get(GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"}, timeout=15) 165- if resp.status_code != 200: 166- raise GmailOauthError(f"userinfo failed: {resp.status_code}") 167- return resp.json() ===== OAuthClientConfig class OAuthClientConfig(Base, TimestampMixin): """An imported Google OAuth client configuration (from client_secret JSON). Secrets stored encrypted. Multiple configs may exist (e.g. per project).""" __tablename__ = "oauth_client_configs" id = Column(Integer, primary_key=True) name = Column(String(200), nullable=False) client_id = Column(String(300), nullable=False) client_secret_enc = Column(String(600), nullable=False) # encrypted redirect_uri = Column(String(500), default="") project_id = Column(String(200), default="") is_active = Column(Boolean, default=True, nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE")) @property def client_secret(self) -> str: return encryption.decrypt(self.client_secret_enc) @client_secret.setter def client_secret(self, value: str) -> None: self.client_secret_enc = encryption.encrypt(value) class GmailAccount(Base, TimestampMixin):