mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(webui): support remote Codex OAuth login (#5174)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
import pytest
|
||||
from oauth_cli_kit.models import OAuthToken
|
||||
|
||||
import nanobot.providers.openai_codex_oauth as codex_oauth
|
||||
from nanobot.providers.openai_codex_oauth import (
|
||||
OpenAICodexOAuthError,
|
||||
OpenAICodexOAuthInputError,
|
||||
complete_openai_codex_oauth_login,
|
||||
start_openai_codex_oauth_login,
|
||||
)
|
||||
|
||||
|
||||
def _authorization_url(state: str = "expected-state") -> str:
|
||||
return f"{codex_oauth.OPENAI_CODEX_PROVIDER.authorize_url}?{urlencode({'state': state})}"
|
||||
|
||||
|
||||
def _wait_for_completion(flow) -> OAuthToken:
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
token = complete_openai_codex_oauth_login(flow)
|
||||
if token is not None:
|
||||
return token
|
||||
time.sleep(0.01)
|
||||
pytest.fail("OAuth flow did not finish")
|
||||
|
||||
|
||||
def _fake_interactive_login(
|
||||
captured: dict[str, object],
|
||||
*,
|
||||
error: Exception | None = None,
|
||||
) -> Callable[..., OAuthToken]:
|
||||
def login(
|
||||
*,
|
||||
print_fn,
|
||||
prompt_fn,
|
||||
provider,
|
||||
proxy,
|
||||
open_browser,
|
||||
) -> OAuthToken:
|
||||
captured.update(
|
||||
provider=provider,
|
||||
proxy=proxy,
|
||||
open_browser=open_browser,
|
||||
)
|
||||
print_fn("Open this URL:")
|
||||
print_fn(_authorization_url())
|
||||
if not open_browser:
|
||||
captured["callback_url"] = prompt_fn("Paste callback URL")
|
||||
if error is not None:
|
||||
raise error
|
||||
return OAuthToken(
|
||||
access="access-token",
|
||||
refresh="refresh-token",
|
||||
expires=2_000_000_000_000,
|
||||
account_id="acct-test",
|
||||
)
|
||||
|
||||
return login
|
||||
|
||||
|
||||
def test_authorization_url_comes_from_oauth_cli_kit() -> None:
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=2,
|
||||
open_browser=False,
|
||||
)
|
||||
try:
|
||||
params = parse_qs(urlsplit(flow.authorization_url).query)
|
||||
assert params["response_type"] == ["code"]
|
||||
assert params["client_id"] == [codex_oauth.OPENAI_CODEX_PROVIDER.client_id]
|
||||
assert params["redirect_uri"] == [codex_oauth.OPENAI_CODEX_PROVIDER.redirect_uri]
|
||||
assert params["code_challenge_method"] == ["S256"]
|
||||
assert params["code_challenge"]
|
||||
assert params["state"]
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def test_local_flow_delegates_browser_and_callback_to_public_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(timeout_s=5)
|
||||
|
||||
try:
|
||||
token = _wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert token.account_id == "acct-test"
|
||||
assert captured == {
|
||||
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||
"proxy": None,
|
||||
"open_browser": True,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_flow_delegates_pasted_callback_to_public_oauth_cli_kit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
proxy="http://127.0.0.1:7890",
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "authorization-code", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
assert complete_openai_codex_oauth_login(flow) is None
|
||||
with pytest.raises(OpenAICodexOAuthInputError, match="full callback URL"):
|
||||
complete_openai_codex_oauth_login(flow, "authorization-code")
|
||||
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||
if token is None:
|
||||
token = _wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert token is not None
|
||||
assert token.account_id == "acct-test"
|
||||
assert captured == {
|
||||
"provider": codex_oauth.OPENAI_CODEX_PROVIDER,
|
||||
"proxy": "http://127.0.0.1:7890",
|
||||
"open_browser": False,
|
||||
"callback_url": callback_url,
|
||||
}
|
||||
|
||||
|
||||
def test_remote_flow_rejects_callback_from_another_login(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "authorization-code", "state": "wrong-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthInputError, match="does not belong"):
|
||||
complete_openai_codex_oauth_login(flow, callback_url)
|
||||
assert "callback_url" not in captured
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
|
||||
def test_remote_flow_reports_authorization_denial_without_exchanging_code(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(captured),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"error": "access_denied", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthError, match="authorization server"):
|
||||
complete_openai_codex_oauth_login(flow, callback_url)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert "callback_url" not in captured
|
||||
|
||||
|
||||
def test_dependency_error_is_bounded_and_does_not_expose_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login(
|
||||
captured,
|
||||
error=RuntimeError("Token exchange failed: 400 secret-code upstream-body"),
|
||||
),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=5,
|
||||
open_browser=False,
|
||||
)
|
||||
callback_url = (
|
||||
"http://localhost:1455/auth/callback?"
|
||||
+ urlencode({"code": "secret-code", "state": "expected-state"})
|
||||
)
|
||||
try:
|
||||
with pytest.raises(OpenAICodexOAuthError) as exc:
|
||||
token = complete_openai_codex_oauth_login(flow, callback_url)
|
||||
if token is None:
|
||||
_wait_for_completion(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
|
||||
assert str(exc.value) == "OpenAI Codex OAuth token exchange failed with HTTP 400."
|
||||
assert "secret-code" not in str(exc.value)
|
||||
|
||||
|
||||
def test_remote_flow_expires_while_waiting_for_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"login_oauth_interactive",
|
||||
_fake_interactive_login({}),
|
||||
)
|
||||
flow = start_openai_codex_oauth_login(
|
||||
timeout_s=0.05,
|
||||
open_browser=False,
|
||||
)
|
||||
try:
|
||||
time.sleep(0.08)
|
||||
with pytest.raises(OpenAICodexOAuthError, match="expired"):
|
||||
complete_openai_codex_oauth_login(flow)
|
||||
finally:
|
||||
flow.cancel()
|
||||
Reference in New Issue
Block a user