feat(llm): sign in with a ChatGPT plan (#1475)

- chatgpt_auth signs in through OpenAI's flow for open-source apps (PKCE, loopback or pasted redirect, verified ID token, plan scope) and keeps the sign-in in ~/.tradingagents/chatgpt-auth.json, readable only by its owner
- access tokens renew under a lock shared across threads and processes, from the refresh token on disk
- `tradingagents login chatgpt` and `tradingagents logout chatgpt`; logout revokes the sign-in at OpenAI
This commit is contained in:
Yijia-Xiao
2026-10-16 21:12:14 +00:00
parent 071a6cea2a
commit 69042d6d33
5 changed files with 916 additions and 0 deletions
+424
View File
@@ -0,0 +1,424 @@
"""Sign in with ChatGPT: the sign-in is checked, kept private, renewed once, and revoked.
OpenAI is replaced by a fake that signs ID tokens with a key made here, so every
check of the sign-in (signature, issuer, audience, nonce, plan scope) runs for real.
"""
import base64
import json
import socket
import stat
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from tradingagents.llm_clients import chatgpt_auth as auth
# The suite refuses all connections; the loopback test lets 127.0.0.1 through only.
_CONNECT, _GETADDRINFO = socket.socket.connect, socket.getaddrinfo
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
CLIENT = "oaiapp_test"
SUBJECT = "user-123"
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _id_token(key=KEY, **claims) -> str:
payload = {"iss": auth.ISSUER, "aud": CLIENT, "sub": SUBJECT, "email": "a@example.com",
"exp": time.time() + 3600, **claims}
head = _b64(json.dumps({"alg": "RS256", "kid": "k1"}).encode())
body = _b64(json.dumps(payload).encode())
signature = key.sign(f"{head}.{body}".encode(), padding.PKCS1v15(), hashes.SHA256())
return f"{head}.{body}.{_b64(signature)}"
def _jwks():
numbers = KEY.public_key().public_numbers()
return {"keys": [{"kid": "k1", "kty": "RSA", "e": _b64(numbers.e.to_bytes(3, "big")),
"n": _b64(numbers.n.to_bytes(256, "big"))}]}
class _Response:
def __init__(self, body, status=200):
self.body, self.status_code = body, status
def json(self):
return self.body
class FakeOpenAI:
"""Answers the JWKS, token and revoke endpoints and records what was sent."""
def __init__(self, token_body=None, status=200):
self.posts = []
self.token_body = token_body
self.status = status
self.lock = threading.Lock()
self.keys_reachable = True
self.revoke_status = 200
self.post_error = None
def get(self, url, timeout):
assert url == auth.JWKS_URL
if not self.keys_reachable:
raise auth.requests.ConnectionError("down")
return _Response(_jwks())
def post(self, url, timeout, data):
with self.lock:
self.posts.append((url, data))
if url == auth.REVOKE_URL:
return _Response({}, self.revoke_status)
if self.post_error is not None:
raise self.post_error
time.sleep(0.05) # let a racing refresher reach the lock
body = self.token_body(data) if callable(self.token_body) else self.token_body
return _Response(body, self.status)
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setattr(auth, "AUTH_FILE", tmp_path / "home" / "chatgpt-auth.json")
monkeypatch.setattr(auth, "_retry_after", 0.0)
return tmp_path
@pytest.fixture
def openai(monkeypatch):
fake = FakeOpenAI()
monkeypatch.setattr(auth.requests, "get", fake.get)
monkeypatch.setattr(auth.requests, "post", fake.post)
return fake
def _signed_in(expires_at, earliest=None, refresh_token="r1"):
auth.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
auth._write({"host_id": "urn:uuid:h", "account": {"client_id": CLIENT, "email": "a@example.com",
"subject": SUBJECT},
"tokens": {"access_token": "a1", "refresh_token": refresh_token,
"expires_at": expires_at, "earliest_refresh_at": earliest}})
def _renewal(data):
return {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(), "scope": auth.SCOPE}
# --- the ID token ----------------------------------------------------------------
@pytest.mark.unit
def test_a_genuine_id_token_is_accepted():
assert auth._validated_claims(_id_token(nonce="n"), CLIENT, "n", _jwks()["keys"])["sub"] == SUBJECT
@pytest.mark.unit
@pytest.mark.parametrize("token, nonce", [
(lambda: _id_token(nonce="other"), "n"),
(lambda: _id_token(aud="oaiapp_someone_else", nonce="n"), "n"),
(lambda: _id_token(iss="https://evil.example", nonce="n"), "n"),
(lambda: _id_token(exp=time.time() - 600, nonce="n"), "n"),
])
def test_an_id_token_for_another_sign_in_is_refused(token, nonce):
with pytest.raises(auth.ChatGPTAuthError):
auth._validated_claims(token(), CLIENT, nonce, _jwks()["keys"])
@pytest.mark.unit
def test_an_id_token_signed_by_another_key_is_refused():
stranger = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(auth.ChatGPTAuthError, match="do not verify"):
auth._validated_claims(_id_token(key=stranger, nonce="n"), CLIENT, "n", _jwks()["keys"])
# --- tokens ----------------------------------------------------------------------
@pytest.mark.unit
def test_nobody_signed_in_is_told_how_to_sign_in(home, openai):
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
auth.credential()
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
auth.access_token()
assert openai.posts == []
@pytest.mark.unit
def test_a_fresh_token_is_used_without_asking_openai(home, openai):
_signed_in(expires_at=time.time() + 3000)
assert auth.credential()() == "a1"
assert openai.posts == []
@pytest.mark.unit
def test_a_token_near_expiry_is_renewed_with_the_stored_refresh_token(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
assert auth.access_token() == "a2"
(url, sent), = openai.posts
assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT
stored = json.loads(auth.AUTH_FILE.read_text())
assert stored["tokens"]["refresh_token"] == "r2" and stored["account"]["subject"] == SUBJECT
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
@pytest.mark.unit
def test_renewal_waits_for_the_time_openai_allows_while_the_token_still_works(home, openai):
now = time.time()
_signed_in(expires_at=now + 200, earliest=now + 100)
assert auth.access_token() == "a1"
assert openai.posts == []
@pytest.mark.unit
def test_concurrent_runs_renew_once_and_never_replay_a_used_refresh_token(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
tokens = []
threads = [threading.Thread(target=lambda: tokens.append(auth.access_token())) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert tokens == ["a2"] * 4
assert [sent["refresh_token"] for _, sent in openai.posts] == ["r1"]
@pytest.mark.unit
def test_a_refused_renewal_asks_to_sign_in_again_and_keeps_the_file(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body, openai.status = {"error": "invalid_grant"}, 400
with pytest.raises(auth.ChatGPTAuthError, match="invalid_grant.*login chatgpt"):
auth.access_token()
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_a_renewal_for_another_account_is_refused(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = lambda data: {**_renewal(data), "id_token": _id_token(sub="someone-else")}
with pytest.raises(auth.ChatGPTAuthError, match="another account"):
auth.access_token()
# --- signing in and out ------------------------------------------------------------
def _nonce_of(printed):
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))["nonce"]
def _paster(printed, edit=lambda query: query):
calls = []
def read_line():
calls.append(1)
if len(calls) > 1:
threading.Event().wait()
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))
query = edit({"code": "c1", "state": asked["state"], "client_id": CLIENT})
return f"{asked['redirect_uri']}?{urllib.parse.urlencode(query)}"
return read_line
@pytest.mark.unit
def test_signing_in_by_pasting_the_redirect_saves_a_private_sign_in(home, openai):
printed = []
def token(data):
assert data["grant_type"] == "authorization_code" and data["client_id"] == CLIENT
assert data["code"] == "c1" and data["code_verifier"]
return {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
openai.token_body = token
email = auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert email == "a@example.com"
stored = json.loads(auth.AUTH_FILE.read_text())
assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT}
assert stored["host_id"].startswith("urn:uuid:")
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
assert asked["client_id"] == auth.DYNAMIC_CLIENT_ID and asked["agent_name_hint"] == "TradingAgents"
assert asked["code_challenge_method"] == "S256" and asked["redirect_uri"].startswith("http://127.0.0.1:")
@pytest.mark.unit
def test_an_answer_for_another_sign_in_attempt_is_ignored(home, openai):
printed = []
with pytest.raises(auth.ChatGPTAuthError, match="timed out"):
auth.sign_in(say=printed.append, read_line=_paster(printed, lambda q: {**q, "state": "forged"}),
timeout=1, port=0)
assert any("not the address" in line for line in printed)
assert openai.posts == [] and not auth.AUTH_FILE.exists()
def _status(url):
try:
return urllib.request.urlopen(url, timeout=5).status
except urllib.error.HTTPError as exc:
return exc.code
@pytest.mark.unit
def test_the_browser_answer_reaches_the_loopback_and_strays_are_ignored(home, openai, monkeypatch):
refused_connect, refused_lookup = socket.socket.connect, socket.getaddrinfo
monkeypatch.setattr(socket.socket, "connect", lambda sock, address: (
_CONNECT if address[0] == "127.0.0.1" else refused_connect)(sock, address))
monkeypatch.setattr(socket, "getaddrinfo", lambda host, *a, **k: (
_GETADDRINFO if host == "127.0.0.1" else refused_lookup)(host, *a, **k))
printed = []
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
def browser():
while not any(line.startswith(auth.AUTHORIZE_URL) for line in printed):
time.sleep(0.01)
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
results.append(_status(f"{asked['redirect_uri']}?error=access_denied"))
results.append(_status(f"{asked['redirect_uri']}?code=c1&state={asked['state']}&client_id={CLIENT}"))
results = []
threading.Thread(target=browser, daemon=True).start()
email = auth.sign_in(say=printed.append, read_line=threading.Event().wait, timeout=10, port=0)
assert email == "a@example.com" and results == [400, 200]
@pytest.mark.unit
def test_a_sign_in_without_plan_usage_is_refused(home, openai):
printed = []
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)),
"scope": "openid email offline_access"}
with pytest.raises(auth.ChatGPTAuthError, match="approve plan usage"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert not auth.AUTH_FILE.exists()
@pytest.mark.unit
def test_signing_out_revokes_the_sign_in_and_keeps_only_the_host_id(home, openai):
_signed_in(expires_at=time.time() + 3000)
assert auth.sign_out() == "revoked"
(url, sent), = openai.posts
assert url == auth.REVOKE_URL and sent["token"] == "r1" and sent["client_id"] == CLIENT
assert json.loads(auth.AUTH_FILE.read_text()) == {"host_id": "urn:uuid:h"}
assert auth.signed_in_as() is None
assert auth.sign_out() is None
@pytest.mark.unit
def test_unreachable_keys_stop_a_renewal_before_the_refresh_token_is_spent(home, openai):
_signed_in(expires_at=time.time() - 1)
openai.token_body, openai.keys_reachable = _renewal, False
with pytest.raises(auth.ChatGPTUnreachable, match="Could not reach OpenAI"):
auth.access_token()
assert openai.posts == []
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
@pytest.mark.parametrize("failure", ["keys", "network", 429, 503])
def test_a_temporary_failure_keeps_a_token_that_still_works(home, openai, failure):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
if failure == "keys":
openai.keys_reachable = False
elif failure == "network":
openai.post_error = auth.requests.ConnectionError("reset")
else:
openai.token_body, openai.status = {"error": "busy"}, failure
assert auth.access_token() == "a1"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
if failure == "keys":
assert openai.posts == []
@pytest.mark.unit
@pytest.mark.parametrize("failure", ["network", 408, 503])
def test_a_temporary_failure_after_expiry_says_to_try_again_not_to_sign_in(home, openai, failure):
_signed_in(expires_at=time.time() - 1)
if failure == "network":
openai.post_error = auth.requests.Timeout("slow")
else:
openai.token_body, openai.status = {"error": "busy"}, failure
with pytest.raises(auth.ChatGPTUnreachable) as raised:
auth.access_token()
assert "try again" in str(raised.value) and "login" not in str(raised.value)
@pytest.mark.unit
def test_signing_in_again_revokes_the_replaced_sign_in(home, openai):
_signed_in(expires_at=time.time() + 3000, refresh_token="old")
printed = []
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert openai.posts[-1][0] == auth.REVOKE_URL and openai.posts[-1][1]["token"] == "old"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r2"
@pytest.mark.unit
def test_another_account_cannot_replace_the_signed_in_one(home, openai):
_signed_in(expires_at=time.time() + 3000)
printed = []
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed), sub="someone-else"),
"scope": auth.SCOPE}
with pytest.raises(auth.ChatGPTAuthError, match="logout chatgpt"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_signing_out_while_openai_is_unreachable_still_forgets_the_sign_in(home, openai):
_signed_in(expires_at=time.time() + 3000)
openai.revoke_status = 503
assert auth.sign_out() == "forgotten"
assert auth.signed_in_as() is None
@pytest.mark.unit
def test_after_a_temporary_failure_renewal_waits_a_minute_before_trying_again(home, openai):
_signed_in(expires_at=time.time() + 200)
openai.post_error = auth.requests.ConnectionError("reset")
clock = [time.time()]
assert auth.access_token(now=lambda: clock[0]) == "a1"
assert auth.access_token(now=lambda: clock[0] + 30) == "a1"
assert len(openai.posts) == 1
openai.post_error, openai.token_body = None, _renewal
assert auth.access_token(now=lambda: clock[0] + 61) == "a2"
@pytest.mark.unit
def test_after_expiry_a_failed_renewal_is_not_retried_within_the_minute(home, openai):
_signed_in(expires_at=time.time() - 1)
openai.post_error = auth.requests.ConnectionError("reset")
for _ in range(3):
with pytest.raises(auth.ChatGPTUnreachable, match="try again"):
auth.access_token()
assert len(openai.posts) == 1
@pytest.mark.unit
def test_an_unreadable_renewal_answer_keeps_a_token_that_still_works(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = lambda data: ["not", "a", "token"]
assert auth.access_token() == "a1"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_a_network_failure_while_signing_in_is_reported_not_raised_raw(home, openai):
printed = []
openai.post_error = auth.requests.ConnectionError("reset")
with pytest.raises(auth.ChatGPTUnreachable, match="sign you in to ChatGPT.*try again"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert not auth.AUTH_FILE.exists()
+50
View File
@@ -180,3 +180,53 @@ def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args])
assert result.exit_code == 1
@pytest.mark.unit
def test_login_and_logout_chatgpt_do_not_run_an_analysis(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
monkeypatch.setattr(chatgpt_auth, "sign_in", lambda say: "a@example.com")
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: "revoked")
signed_in = runner.invoke(m.app, ["login", "chatgpt"])
assert signed_in.exit_code == 0 and "Signed in to ChatGPT as a@example.com" in signed_in.output
signed_out = runner.invoke(m.app, ["logout", "chatgpt"])
assert signed_out.exit_code == 0 and "Signed out of ChatGPT" in signed_out.output
assert runner.invoke(m.app, ["login", "claude"]).exit_code != 0
assert calls == []
@pytest.mark.unit
def test_a_failed_login_exits_with_the_reason(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
def refuse(say):
raise chatgpt_auth.ChatGPTAuthError("Signing in with ChatGPT timed out")
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
result = runner.invoke(m.app, ["login", "chatgpt"])
assert result.exit_code == 1 and "timed out" in result.output
@pytest.mark.unit
@pytest.mark.parametrize("outcome, said", [
(None, "Not signed in"), ("forgotten", "could not be reached to revoke"),
])
def test_logout_says_whether_openai_revoked_the_sign_in(runner, monkeypatch, outcome, said):
from tradingagents.llm_clients import chatgpt_auth
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: outcome)
result = runner.invoke(m.app, ["logout", "chatgpt"])
assert result.exit_code == 0 and said in " ".join(result.output.split())
@pytest.mark.unit
def test_a_failure_message_with_brackets_is_printed_as_text(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
def refuse(say):
raise chatgpt_auth.ChatGPTAuthError("ChatGPT sign-in failed ([/bogus]); sign in again.")
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
result = runner.invoke(m.app, ["login", "chatgpt"])
assert result.exit_code == 1 and "[/bogus]" in result.output