Files
codex-app-server/cloud/tests/test_oauth_real_login.py
T
host 0ed301b010 OAuth real browser login: GET callback, userinfo, API key issuance
Add GET /oauth/{provider}/callback (code+state from query, client_id
and redirect_uri recovered from the state record) alongside the
existing POST callback. Callback now fetches real userinfo
(GitHub api.github.com/user, Google openidconnect userinfo) and
issues a usable local API key on success: new users get the
create_user key, existing users rotate (old key dead, audit
oauth_login + api_key_rotated). Key plaintext appears only once
on the success page/response; failures are 400 HTML/JSON without
secrets, and a missing client_secret fails before any network call.
State remains one-time, 10 minutes, provider/redirect_uri matched.
Tests: 10 new OAuth real-login tests pass; host 27, cloud 249
(4 skipped are the pre-existing real-Postgres tests).
Real end-to-end still needs a registered OAuth App and a public
HTTPS callback entry (server still binds localhost only).
2026-10-26 03:35:40 +08:00

245 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OAuth 真实浏览器登录的 HTTP 端点测试。
不联网、不向真实 GitHub/Google 发请求:server 的注入点
(server.oauth_http_client / server.oauth_userinfo_client)挂 fake;
真实 userinfo 端点本身只由 cloud.oauth.default_userinfo_client
拼 URL,不在这里调真实网络。测试里出现的 token、key 一律是
fake 占位,绝无真实 client_secret、真实 token、真实 GitHub 账号。
"""
import http.client
import os
import re
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.oauth import OAuthStateStore
from cloud.server import create_server
def _fake_token_client(result=None, error=None):
def fake(token_url, payload):
if error is not None:
return {"error": error}
return dict(result if result is not None else
{"access_token": "fake-token-not-real"})
return fake
def _fake_userinfo(result=None, exc=None):
def fake(access_token):
if exc is not None:
raise exc
return dict(result if result is not None else
{"id": 4242, "login": "gh-user",
"email": "gh-user@example.invalid"})
return fake
class OAuthRealLoginTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
# 默认挂 fake:token 响应故意不带身份字段,逼走 userinfo 路径。
self.server.oauth_http_client = _fake_token_client()
self.server.oauth_userinfo_client = _fake_userinfo()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
headers = {}
payload = None
if body is not None:
import json
payload = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=headers)
resp = conn.getresponse()
data = resp.read().decode("utf-8", "replace")
finally:
conn.close()
return resp.status, data
def _login_state(self):
status, _ = self._req(
"GET",
"/oauth/github/login?client_id=test-client"
"&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb")
self.assertEqual(status, 200)
store = OAuthStateStore(self.db_path)
try:
row = store.conn.execute(
"SELECT state FROM oauth_states ORDER BY created_at DESC"
" LIMIT 1").fetchone()
finally:
store.conn.close()
return row[0]
def _api_key_in_page(self, page):
match = re.search(r"<code>([^<]+)</code>", page)
self.assertIsNotNone(match, page)
return match.group(1)
# -- GET 回调成功:发 key、key 可用、轮换旧 key 失效 -------------
def test_get_callback_success_issues_key_and_rotation(self):
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=fake-code&state=" + state)
self.assertEqual(status, 200, page)
self.assertIn("gh-user", page)
self.assertIn("new user: yes", page)
self.assertNotIn("fake-token-not-real", page)
self.assertNotIn("client_secret", page)
key1 = self._api_key_in_page(page)
user_id = self.auth.authenticate(key1)
self.assertIsNotNone(user_id)
# 同一身份第二次登录:新 key、旧 key 失效、审计有轮换记录。
state2 = self._login_state()
status, page2 = self._req(
"GET", "/oauth/github/callback?code=fake-code&state=" + state2)
self.assertEqual(status, 200, page2)
self.assertIn("new user: no", page2)
self.assertIn("rotated", page2)
key2 = self._api_key_in_page(page2)
self.assertNotEqual(key1, key2)
self.assertIsNone(self.auth.authenticate(key1))
self.assertEqual(self.auth.authenticate(key2), user_id)
actions = [row["action"] for row in self.auth.list_audit("", limit=50)]
self.assertIn("oauth_login", actions)
self.assertIn("api_key_rotated", actions)
def test_post_callback_success_returns_key_once(self):
state = self._login_state()
import json
status, body = self._req(
"POST", "/oauth/github/callback",
{"code": "fake-code", "state": state,
"client_id": "test-client",
"redirect_uri": "http://127.0.0.1/cb"})
parsed = json.loads(body)
self.assertEqual(status, 200, body)
self.assertIn("api_key", parsed)
self.assertFalse(parsed["api_key_rotated"])
self.assertEqual(self.auth.authenticate(parsed["api_key"]),
parsed["user_id"])
# -- GET 回调失败路径 ----------------------------------------------
def test_get_callback_bad_state_400_no_user(self):
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=no-such-state")
self.assertEqual(status, 400)
self.assertNotIn("fake-token-not-real", page)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_expired_state_400(self):
state = self._login_state()
store = OAuthStateStore(self.db_path)
try:
store.conn.execute(
"UPDATE oauth_states SET created_at = ? WHERE state = ?",
(time.time() - 601, state))
store.conn.commit()
finally:
store.conn.close()
status, _ = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400)
def test_get_callback_state_provider_mismatch_400(self):
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/google/callback?code=x&state=" + state)
self.assertEqual(status, 400)
def test_get_callback_token_exchange_failure_400_no_user(self):
self.server.oauth_http_client = _fake_token_client(
error="bad_verification_code")
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/github/callback?code=bad&state=" + state)
self.assertEqual(status, 400)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_userinfo_failure_400_no_user(self):
self.server.oauth_userinfo_client = _fake_userinfo(exc=RuntimeError("down"))
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400, page)
self.assertIn("userinfo", page)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_userinfo_empty_400_no_user(self):
self.server.oauth_userinfo_client = _fake_userinfo(result={})
state = self._login_state()
status, _ = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
self.assertEqual(status, 400)
count = self.auth.conn.execute(
"SELECT COUNT(*) FROM users").fetchone()[0]
self.assertEqual(count, 0)
def test_get_callback_secret_missing_400_before_network(self):
# 真实路径(不注入 fake):无 secret 时换 token 前就拒绝,
# 不会走到真实网络;fake userinfo 在此路径下不被使用。
self.server.oauth_http_client = None
self.server.oauth_userinfo_client = None
old = os.environ.pop("GITHUB_OAUTH_CLIENT_SECRET", None)
try:
state = self._login_state()
status, page = self._req(
"GET", "/oauth/github/callback?code=x&state=" + state)
finally:
if old is not None:
os.environ["GITHUB_OAUTH_CLIENT_SECRET"] = old
self.assertEqual(status, 400, page)
self.assertIn("client_secret", page)
# -- AuthStore 轮换本身 ---------------------------------------------
def test_rotate_api_key_old_key_dead(self):
user_id, old_key = self.auth.create_user("someone")
new_key = self.auth.rotate_api_key(user_id)
self.assertNotEqual(old_key, new_key)
self.assertIsNone(self.auth.authenticate(old_key))
self.assertEqual(self.auth.authenticate(new_key), user_id)
with self.assertRaises(ValueError):
self.auth.rotate_api_key("no-such-user")
if __name__ == "__main__":
unittest.main()