diff --git a/src/providers/chatgpt.py b/src/providers/chatgpt.py index 86707f8..1379647 100644 --- a/src/providers/chatgpt.py +++ b/src/providers/chatgpt.py @@ -4,19 +4,23 @@ import logging import os from typing import Any -from src.providers.base import BaseProvider, ProviderError +from src.providers.base import BaseProvider, ProviderError, REQUEST_TIMEOUT logger = logging.getLogger(__name__) BASE_URL = "https://chatgpt.com/backend-api" +AUTH_SESSION_URL = "https://chatgpt.com/api/auth/session" class ChatGPTProvider(BaseProvider): """Provider for ChatGPT conversations via the internal web API. - Authentication: Authorization: Bearer - Token: __Secure-next-auth.session-token cookie value (a JWT). - Typical validity: ~7 days. + Authentication is a two-step process: + 1. Send __Secure-next-auth.session-token as a Cookie header to + /api/auth/session to obtain a short-lived accessToken. + 2. Use that accessToken as the Bearer token for all backend-api calls. + + Token: __Secure-next-auth.session-token cookie (~7 day lifetime). """ provider_name = "chatgpt" @@ -33,20 +37,60 @@ class ChatGPTProvider(BaseProvider): "Run 'python -m src.main auth' to configure it." ), ) - # Never log the token value + self._session_token = token self._session.headers.update( { - "Authorization": f"Bearer {token}", "Referer": "https://chatgpt.com/", "Origin": "https://chatgpt.com", } ) - logger.debug("[chatgpt] Session initialised (token: [REDACTED])") + # Exchange the session cookie for an access token immediately + self._access_token: str = self._fetch_access_token(token) + self._session.headers["Authorization"] = f"Bearer {self._access_token}" + logger.debug("[chatgpt] Session initialised — access token obtained (token: [REDACTED])") + + def _fetch_access_token(self, session_token: str) -> str: + """Exchange the session cookie for a Bearer access token. + + Calls GET /api/auth/session with the session cookie, which returns + {"accessToken": "...", "user": {...}}. + """ + logger.debug("[chatgpt] Fetching access token from %s", AUTH_SESSION_URL) + try: + resp = self._session.get( + AUTH_SESSION_URL, + headers={"Cookie": f"__Secure-next-auth.session-token={session_token}"}, + timeout=REQUEST_TIMEOUT, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + raise ProviderError( + self.provider_name, + "fetch_access_token", + RuntimeError( + f"Could not exchange session token for access token: {e}. " + "Check that your CHATGPT_SESSION_TOKEN is current." + ), + ) from e + + access_token = data.get("accessToken") + if not access_token: + raise ProviderError( + self.provider_name, + "fetch_access_token", + RuntimeError( + "No accessToken in /api/auth/session response. " + "Your session token may be expired — run 'python -m src.main auth' to refresh." + ), + ) + return access_token def _handle_401(self) -> None: msg = ( "[chatgpt] Authentication failed (401 Unauthorized). " "Your __Secure-next-auth.session-token has likely expired (~7 day lifetime). " + "The session token is used to obtain a short-lived access token via /api/auth/session. " "To refresh: open chatgpt.com in Chrome → F12 → Application → Cookies " "→ find '__Secure-next-auth.session-token' → copy the value. " "Then run 'python -m src.main auth' or update CHATGPT_SESSION_TOKEN in .env."