| 1 | from __future__ import annotations |
| 2 | |
| 3 | import json |
| 4 | import sys |
| 5 | import types |
| 6 | import time |
| 7 | from dataclasses import dataclass |
| 8 | from pathlib import Path |
| 9 | from urllib.parse import parse_qs, urlparse |
| 10 | |
| 11 | import pytest |
| 12 | |
| 13 | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| 14 | |
| 15 | @dataclass(frozen=True) |
| 16 | class PkcePair: |
| 17 | verifier: str |
| 18 | challenge: str |
| 19 | |
| 20 | |
| 21 | from plugins._oauth.helpers.providers.base import ProviderError, XAI_GROK_PROVIDER_ID |
| 22 | from plugins._oauth.helpers.providers import xai_grok as xai |
| 23 | from plugins._oauth.helpers.state import pop_attempt, put_attempt |
| 24 | |
| 25 | |
| 26 | def _discovery() -> dict[str, str]: |
| 27 | return { |
| 28 | "authorization_endpoint": "https://auth.x.ai/oauth/authorize", |
| 29 | "token_endpoint": "https://auth.x.ai/oauth/token", |
| 30 | } |
| 31 | |
| 32 | |
| 33 | def test_start_login_builds_xai_pkce_authorize_url_without_network(monkeypatch): |
| 34 | provider = xai.XaiGrokOAuthProvider() |
| 35 | fake_codex = types.ModuleType("plugins._oauth.helpers.codex") |
| 36 | fake_codex.PkcePair = PkcePair |
| 37 | fake_codex.generate_pkce = lambda: PkcePair("verifier", "challenge") |
| 38 | fake_codex.generate_state = lambda: "state-1" |
| 39 | monkeypatch.setitem(sys.modules, "plugins._oauth.helpers.codex", fake_codex) |
| 40 | monkeypatch.setattr(provider, "discovery", _discovery) |
| 41 | |
| 42 | result = provider.start_login({}) |
| 43 | |
| 44 | try: |
| 45 | assert result.ok is True |
| 46 | assert result.provider_id == XAI_GROK_PROVIDER_ID |
| 47 | assert result.flow == "browser_pkce" |
| 48 | assert result.redirect_uri == "http://127.0.0.1:56121/callback" |
| 49 | |
| 50 | parsed = urlparse(result.auth_url) |
| 51 | query = parse_qs(parsed.query) |
| 52 | assert result.auth_url.startswith("https://auth.x.ai/oauth/authorize?") |
| 53 | assert query["response_type"] == ["code"] |
| 54 | assert query["client_id"] == [xai.XAI_CLIENT_ID] |
| 55 | assert query["scope"] == [xai.XAI_SCOPE] |
| 56 | assert query["code_challenge"] == ["challenge"] |
| 57 | assert query["code_challenge_method"] == ["S256"] |
| 58 | assert query["state"] == ["state-1"] |
| 59 | assert query["plan"] == ["generic"] |
| 60 | assert query["referrer"] == ["agent-zero"] |
| 61 | assert query["redirect_uri"] == ["http://127.0.0.1:56121/callback"] |
| 62 | assert query["nonce"] |
| 63 | finally: |
| 64 | pop_attempt("state-1") |
| 65 | |
| 66 | |
| 67 | @pytest.mark.parametrize( |
| 68 | ("raw", "expected"), |
| 69 | [ |
| 70 | ( |
| 71 | "http://127.0.0.1:56121/callback?code=abc&state=state-1", |
| 72 | {"code": "abc", "state": "state-1", "error": None, "error_description": None}, |
| 73 | ), |
| 74 | ("?code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}), |
| 75 | ("code=abc&state=state-1", {"code": "abc", "state": "state-1", "error": None, "error_description": None}), |
| 76 | ("abc", {"code": "abc", "state": None, "error": None, "error_description": None}), |
| 77 | ], |
| 78 | ) |
| 79 | def test_parse_manual_callback_accepts_url_query_and_bare_code(raw, expected): |
| 80 | assert xai.parse_manual_callback(raw) == expected |
| 81 | |
| 82 | |
| 83 | def test_manual_callback_rejects_state_mismatch(monkeypatch): |
| 84 | provider = xai.XaiGrokOAuthProvider() |
| 85 | put_attempt( |
| 86 | "state-good", |
| 87 | "verifier", |
| 88 | xai.XAI_REDIRECT_URI, |
| 89 | provider_id=XAI_GROK_PROVIDER_ID, |
| 90 | extra={"token_endpoint": "https://auth.x.ai/oauth/token"}, |
| 91 | ) |
| 92 | monkeypatch.setattr(provider, "discovery", _discovery) |
| 93 | |
| 94 | try: |
| 95 | result = provider.manual_callback({"callback": "code=abc&state=state-bad"}) |
| 96 | |
| 97 | assert result.ok is False |
| 98 | assert "state mismatch" in result.error.lower() |
| 99 | finally: |
| 100 | pop_attempt("state-good") |
| 101 | pop_attempt("state-bad") |
| 102 | |
| 103 | |
| 104 | def test_manual_callback_exchanges_code_and_stores_tokens(tmp_path, monkeypatch): |
| 105 | provider = xai.XaiGrokOAuthProvider() |
| 106 | auth_path = tmp_path / "auth.json" |
| 107 | put_attempt( |
| 108 | "state-1", |
| 109 | "verifier-1", |
| 110 | xai.XAI_REDIRECT_URI, |
| 111 | provider_id=XAI_GROK_PROVIDER_ID, |
| 112 | extra={"token_endpoint": "https://auth.x.ai/oauth/token", "code_challenge": "challenge-1"}, |
| 113 | ) |
| 114 | calls = [] |
| 115 | |
| 116 | monkeypatch.setattr(provider, "auth_path", lambda: auth_path) |
| 117 | monkeypatch.setattr(provider, "discovery", _discovery) |
| 118 | |
| 119 | def fake_exchange(token_endpoint, code, redirect_uri, code_verifier, code_challenge): |
| 120 | calls.append((token_endpoint, code, redirect_uri, code_verifier, code_challenge)) |
| 121 | return { |
| 122 | "access_token": "access-token", |
| 123 | "refresh_token": "refresh-token", |
| 124 | "expires_in": 3600, |
| 125 | "id_token": "id-token", |
| 126 | "token_type": "Bearer", |
| 127 | } |
| 128 | |
| 129 | monkeypatch.setattr(provider, "exchange_code", fake_exchange) |
| 130 | |
| 131 | result = provider.manual_callback( |
| 132 | {"callback": "http://127.0.0.1:56121/callback?code=code-1&state=state-1"} |
| 133 | ) |
| 134 | |
| 135 | assert result.ok is True |
| 136 | assert result.completed is True |
| 137 | assert result.account_label == "xAI Grok" |
| 138 | assert calls == [ |
| 139 | ( |
| 140 | "https://auth.x.ai/oauth/token", |
| 141 | "code-1", |
| 142 | "http://127.0.0.1:56121/callback", |
| 143 | "verifier-1", |
| 144 | "challenge-1", |
| 145 | ) |
| 146 | ] |
| 147 | saved = json.loads(auth_path.read_text(encoding="utf-8")) |
| 148 | assert saved["provider"] == XAI_GROK_PROVIDER_ID |
| 149 | assert saved["access"] == "access-token" |
| 150 | assert saved["refresh"] == "refresh-token" |
| 151 | |
| 152 | |
| 153 | def test_models_returns_curated_list_without_network(monkeypatch): |
| 154 | provider = xai.XaiGrokOAuthProvider() |
| 155 | monkeypatch.setattr(provider, "read_auth", lambda: {}) |
| 156 | |
| 157 | models = provider.models() |
| 158 | |
| 159 | assert models[:4] == [ |
| 160 | "grok-4.3", |
| 161 | "grok-4.20-0309-reasoning", |
| 162 | "grok-4.20-0309-non-reasoning", |
| 163 | "grok-4.20-multi-agent-0309", |
| 164 | ] |
| 165 | |
| 166 | |
| 167 | def test_exchange_code_http_403_explains_oauth_tier_restriction(monkeypatch): |
| 168 | class FakeResponse: |
| 169 | ok = False |
| 170 | status_code = 403 |
| 171 | text = "forbidden" |
| 172 | |
| 173 | def json(self): |
| 174 | return {"error": "forbidden"} |
| 175 | |
| 176 | class FakeRequests: |
| 177 | @staticmethod |
| 178 | def post(*args, **kwargs): |
| 179 | return FakeResponse() |
| 180 | |
| 181 | monkeypatch.setitem(sys.modules, "requests", FakeRequests) |
| 182 | |
| 183 | provider = xai.XaiGrokOAuthProvider() |
| 184 | with pytest.raises(ProviderError) as exc_info: |
| 185 | provider.exchange_code( |
| 186 | "https://auth.x.ai/oauth/token", |
| 187 | "code", |
| 188 | xai.XAI_REDIRECT_URI, |
| 189 | "verifier", |
| 190 | "challenge", |
| 191 | ) |
| 192 | |
| 193 | assert exc_info.value.status == 403 |
| 194 | assert "restricted by tier" in str(exc_info.value) |
| 195 | assert "API-key `xai` provider" in str(exc_info.value) |
| 196 | |
| 197 | |
| 198 | def test_refresh_403_preserves_oauth_tier_guidance(monkeypatch): |
| 199 | class FakeResponse: |
| 200 | ok = False |
| 201 | status_code = 403 |
| 202 | |
| 203 | def json(self): |
| 204 | return {"error": "forbidden"} |
| 205 | |
| 206 | class FakeRequests: |
| 207 | @staticmethod |
| 208 | def post(*args, **kwargs): |
| 209 | return FakeResponse() |
| 210 | |
| 211 | monkeypatch.setitem(sys.modules, "requests", FakeRequests) |
| 212 | |
| 213 | provider = xai.XaiGrokOAuthProvider() |
| 214 | monkeypatch.setattr( |
| 215 | provider, |
| 216 | "read_auth", |
| 217 | lambda: { |
| 218 | "access": "expired-access-token", |
| 219 | "refresh": "refresh-token", |
| 220 | "expires": 1, |
| 221 | "token_endpoint": "https://auth.x.ai/oauth/token", |
| 222 | }, |
| 223 | ) |
| 224 | |
| 225 | with pytest.raises(ProviderError) as exc_info: |
| 226 | provider.ensure_fresh_auth() |
| 227 | |
| 228 | assert exc_info.value.status == 403 |
| 229 | assert exc_info.value.code == "oauth_tier_restricted" |
| 230 | assert "restricted by tier" in str(exc_info.value) |
| 231 | assert "API-key `xai` provider" in str(exc_info.value) |
| 232 | |
| 233 | |
| 234 | def test_ensure_fresh_auth_rejects_malicious_stored_token_endpoint(monkeypatch): |
| 235 | calls = [] |
| 236 | |
| 237 | class FakeRequests: |
| 238 | @staticmethod |
| 239 | def post(*args, **kwargs): |
| 240 | calls.append((args, kwargs)) |
| 241 | raise AssertionError("malicious token endpoint must not be called") |
| 242 | |
| 243 | monkeypatch.setitem(sys.modules, "requests", FakeRequests) |
| 244 | |
| 245 | provider = xai.XaiGrokOAuthProvider() |
| 246 | monkeypatch.setattr( |
| 247 | provider, |
| 248 | "read_auth", |
| 249 | lambda: { |
| 250 | "access": "expired-access-token", |
| 251 | "refresh": "refresh-token", |
| 252 | "expires": 1, |
| 253 | "token_endpoint": "https://evil.example/oauth/token", |
| 254 | }, |
| 255 | ) |
| 256 | |
| 257 | with pytest.raises(ProviderError) as exc_info: |
| 258 | provider.ensure_fresh_auth() |
| 259 | |
| 260 | assert exc_info.value.code == "invalid_token_endpoint" |
| 261 | assert calls == [] |
| 262 | |
| 263 | |
| 264 | def test_models_does_not_send_bearer_token_to_malicious_base_url(monkeypatch): |
| 265 | calls = [] |
| 266 | |
| 267 | class FakeResponse: |
| 268 | ok = True |
| 269 | |
| 270 | def json(self): |
| 271 | return {"data": [{"id": "safe-model"}]} |
| 272 | |
| 273 | class FakeRequests: |
| 274 | @staticmethod |
| 275 | def get(url, headers, timeout): |
| 276 | calls.append((url, headers, timeout)) |
| 277 | return FakeResponse() |
| 278 | |
| 279 | monkeypatch.setitem(sys.modules, "requests", FakeRequests) |
| 280 | |
| 281 | provider = xai.XaiGrokOAuthProvider() |
| 282 | monkeypatch.setattr( |
| 283 | provider, |
| 284 | "read_auth", |
| 285 | lambda: { |
| 286 | "access": "access-token", |
| 287 | "refresh": "refresh-token", |
| 288 | "expires": int(time.time() * 1000) + 3_600_000, |
| 289 | "base_url": "https://evil.example/v1", |
| 290 | }, |
| 291 | ) |
| 292 | |
| 293 | assert provider.models() == ["safe-model"] |
| 294 | assert calls == [ |
| 295 | ( |
| 296 | "https://api.x.ai/v1/models", |
| 297 | {"Accept": "application/json", "Authorization": "Bearer access-token"}, |
| 298 | 30, |
| 299 | ) |
| 300 | ] |