Open-source Grok free registration CLI, xai_enroller auth pipeline, local auth service, tests and docs.
246 lines
8.0 KiB
Python
246 lines
8.0 KiB
Python
import base64
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from xai_enroller.models import AuthorizationStatus
|
|
from xai_enroller.protocol import XAIProtocol, XAIProfile
|
|
|
|
|
|
def _unsigned_jwt(payload):
|
|
encoded = base64.urlsafe_b64encode(
|
|
json.dumps(payload, separators=(",", ":")).encode()
|
|
).decode().rstrip("=")
|
|
return f"header.{encoded}.signature"
|
|
|
|
|
|
def test_default_profile_matches_observed_grok_cli_device_contract():
|
|
profile = XAIProfile.default()
|
|
|
|
assert profile.client_id == "b1a00492-073a-47ea-816f-4c329264a828"
|
|
assert profile.scope == "openid profile email offline_access grok-cli:access api:access"
|
|
assert profile.version == "grok-cli-device-v1"
|
|
|
|
|
|
def test_device_flow_uses_discovered_endpoints_and_profile_scope():
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
if request.url.path == "/.well-known/openid-configuration":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
|
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
|
},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_code": "device",
|
|
"user_code": "ABCD",
|
|
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
|
"expires_in": 60,
|
|
"interval": 1,
|
|
},
|
|
)
|
|
|
|
profile = XAIProfile(client_id="client", scope="openid offline_access")
|
|
protocol = XAIProtocol(httpx.AsyncClient(transport=httpx.MockTransport(handler)), profile)
|
|
|
|
async def run():
|
|
flow = await protocol.start_device_flow()
|
|
return flow
|
|
|
|
flow = __import__("asyncio").run(run())
|
|
assert flow.verification_url == "https://accounts.x.ai/oauth2/device?user_code=ABCD"
|
|
assert dict(__import__("urllib.parse", fromlist=["parse_qsl"]).parse_qsl(requests[1].content.decode())) == {
|
|
"client_id": "client",
|
|
"scope": "openid offline_access",
|
|
}
|
|
|
|
|
|
def test_device_flow_uses_configured_poll_interval_when_issuer_omits_it():
|
|
def handler(request):
|
|
if request.url.path == "/.well-known/openid-configuration":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
|
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
|
},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_code": "device",
|
|
"user_code": "ABCD",
|
|
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
|
"expires_in": 60,
|
|
},
|
|
)
|
|
|
|
protocol = XAIProtocol(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
XAIProfile(client_id="client", scope="openid"),
|
|
default_poll_interval=7,
|
|
)
|
|
|
|
flow = __import__("asyncio").run(protocol.start_device_flow())
|
|
|
|
assert flow.interval == 7
|
|
|
|
|
|
def test_device_flow_preserves_complete_verification_url():
|
|
def handler(request):
|
|
if request.url.path == "/.well-known/openid-configuration":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_authorization_endpoint": "https://auth.x.ai/oauth2/device/code",
|
|
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
|
},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"device_code": "device",
|
|
"user_code": "ABCD",
|
|
"verification_uri": "https://accounts.x.ai/oauth2/device",
|
|
"verification_uri_complete": "https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer",
|
|
"expires_in": 60,
|
|
},
|
|
)
|
|
|
|
protocol = XAIProtocol(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
XAIProfile(client_id="client", scope="openid"),
|
|
)
|
|
|
|
flow = __import__("asyncio").run(protocol.start_device_flow())
|
|
|
|
assert flow.verification_url == (
|
|
"https://accounts.x.ai/oauth2/device?user_code=ABCD&state=issuer"
|
|
)
|
|
|
|
|
|
def test_xai_url_allowlist_rejects_missing_hostname():
|
|
assert XAIProtocol._allowed_url("https:///oauth2/device") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"discovery",
|
|
[
|
|
{
|
|
"device_authorization_endpoint": "http://auth.x.ai/device",
|
|
"token_endpoint": "https://auth.x.ai/token",
|
|
},
|
|
{
|
|
"device_authorization_endpoint": "https://evil.example/device",
|
|
"token_endpoint": "https://auth.x.ai/token",
|
|
},
|
|
],
|
|
)
|
|
def test_discovery_rejects_non_xai_or_non_https(discovery):
|
|
def handler(request):
|
|
return httpx.Response(200, json=discovery)
|
|
|
|
protocol = XAIProtocol(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
XAIProfile(client_id="client", scope="openid"),
|
|
)
|
|
with pytest.raises(ValueError, match="allowlist"):
|
|
__import__("asyncio").run(protocol.discover())
|
|
|
|
|
|
def test_token_polling_maps_pending_slow_down_and_denial():
|
|
responses = iter(
|
|
[
|
|
httpx.Response(400, json={"error": "authorization_pending"}),
|
|
httpx.Response(400, json={"error": "slow_down"}),
|
|
httpx.Response(400, json={"error": "access_denied"}),
|
|
]
|
|
)
|
|
|
|
def handler(request):
|
|
return next(responses)
|
|
|
|
protocol = XAIProtocol(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
XAIProfile(client_id="client", scope="openid"),
|
|
sleep=lambda _: __import__("asyncio").sleep(0),
|
|
)
|
|
with pytest.raises(RuntimeError, match="oauth_denied"):
|
|
__import__("asyncio").run(
|
|
protocol.poll_token(
|
|
endpoint="https://auth.x.ai/oauth2/token",
|
|
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
|
|
timeout=1,
|
|
)
|
|
)
|
|
|
|
|
|
def test_token_response_requires_refresh_token_and_never_exposes_body():
|
|
def handler(request):
|
|
return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer"})
|
|
|
|
protocol = XAIProtocol(
|
|
httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
|
XAIProfile(client_id="client", scope="openid"),
|
|
)
|
|
with pytest.raises(RuntimeError) as error:
|
|
__import__("asyncio").run(
|
|
protocol.poll_token(
|
|
endpoint="https://auth.x.ai/oauth2/token",
|
|
flow=type("Flow", (), {"device_code": "device", "interval": 0})(),
|
|
timeout=1,
|
|
)
|
|
)
|
|
assert "access" not in str(error.value)
|
|
|
|
|
|
def test_credential_subject_prefers_id_token_subject():
|
|
credential = XAIProtocol._credential(
|
|
{
|
|
"access_token": _unsigned_jwt({"principal_id": "access-subject"}),
|
|
"refresh_token": "refresh",
|
|
"id_token": _unsigned_jwt(
|
|
{"sub": "stable-subject", "email": "ignored@example.test"}
|
|
),
|
|
"sub": "response-subject",
|
|
},
|
|
"https://auth.x.ai/oauth2/token",
|
|
)
|
|
|
|
assert credential.subject == "stable-subject"
|
|
|
|
|
|
def test_credential_subject_falls_back_to_access_token_principal_id():
|
|
credential = XAIProtocol._credential(
|
|
{
|
|
"access_token": _unsigned_jwt({"principal_id": "stable-principal"}),
|
|
"refresh_token": "refresh",
|
|
"id_token": "malformed",
|
|
"sub": "response-subject",
|
|
},
|
|
"https://auth.x.ai/oauth2/token",
|
|
)
|
|
|
|
assert credential.subject == "stable-principal"
|
|
|
|
|
|
def test_credential_subject_ignores_email_and_falls_back_to_response_subject():
|
|
credential = XAIProtocol._credential(
|
|
{
|
|
"access_token": "not-a-jwt",
|
|
"refresh_token": "refresh",
|
|
"id_token": _unsigned_jwt({"email": "ignored@example.test"}),
|
|
"sub": "response-subject",
|
|
},
|
|
"https://auth.x.ai/oauth2/token",
|
|
)
|
|
|
|
assert credential.subject == "response-subject"
|