Files
openworker/tests/test_openrouter_auth.py
Rohit C Prasad 0126a966af OpenRouter account as its own Subscriptions provider
Sign-in moves to an openrouter-account card; openrouter stays a plain API-key provider.
Each keeps its own profile, so signing out never falls back to a pasted key.
2026-10-06 09:47:56 -07:00

286 lines
10 KiB
Python

"""OpenRouter account login: PKCE, isolated credentials, and stale-flow safety."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from coworker.providers import openrouter_auth
from coworker.secrets import SecretStore
def test_manual_login_uses_pkce_and_keeps_credentials_out_of_status(
tmp_path, monkeypatch
):
store = SecretStore(tmp_path / "secrets.json")
store.put("provider:openrouter", {"api_key": "manual-existing"})
exchanged = []
async def exchange(code, verifier):
exchanged.append((code, verifier))
return "account-secret"
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
auth = openrouter_auth.OpenRouterAuth(store)
async def run():
started = await auth.start(manual=True)
assert started["authorizing"]
assert not started["connected"]
url = urlsplit(started["authorize_url"])
assert url.scheme == "https" and url.netloc == "openrouter.ai"
query = parse_qs(url.query)
assert query["code_challenge_method"] == ["S256"]
completed = await auth.complete("returned-code", started["attempt_id"])
assert completed["connected"]
assert not completed["authorizing"]
code, verifier = exchanged[0]
assert code == "returned-code"
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest())
.rstrip(b"=")
.decode("ascii")
)
assert query["code_challenge"] == [challenge]
assert verifier not in json.dumps(started)
assert "account-secret" not in json.dumps(completed)
assert "account-secret" not in json.dumps(auth.status())
asyncio.run(run())
assert store.get("provider:openrouter-account")["api_key"] == "account-secret"
assert store.get("provider:openrouter")["api_key"] == "manual-existing"
assert openrouter_auth.OpenRouterAuth(store).status()["connected"]
def test_expired_attempt_cannot_complete(tmp_path, monkeypatch):
auth = openrouter_auth.OpenRouterAuth(SecretStore(tmp_path / "secrets.json"))
async def exchange(code, verifier):
raise AssertionError("Expired attempts must not exchange credentials")
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
async def run():
started = await auth.start(manual=True)
auth._expire()
assert not auth.status()["authorizing"]
assert "expired" in auth.status()["error"].lower()
await auth.complete("code", started["attempt_id"])
assert not auth.status()["connected"]
asyncio.run(run())
def test_cancel_during_listener_creation_closes_stale_listener(tmp_path, monkeypatch):
auth = openrouter_auth.OpenRouterAuth(SecretStore(tmp_path / "secrets.json"))
async def run():
entered, release = asyncio.Event(), asyncio.Event()
class Listener:
closed = False
def close(self):
self.closed = True
listener = Listener()
async def bind(*args, **kwargs):
entered.set()
await release.wait()
return listener
monkeypatch.setattr(openrouter_auth.asyncio, "start_server", bind)
pending = asyncio.create_task(auth.start())
await entered.wait()
auth.cancel()
release.set()
status = await pending
assert listener.closed
assert not status["authorizing"]
assert status["authorize_url"] is None
assert auth.server is None
assert auth.timer is None
asyncio.run(run())
def test_account_provider_uses_official_endpoint_and_its_own_key(tmp_path, monkeypatch):
from coworker.providers import registry
monkeypatch.setenv("OPENROUTER_API_KEY", "environment-key")
monkeypatch.setattr(registry, "OpenAIProvider", lambda **kwargs: kwargs)
d = registry.get_descriptor("openrouter-account")
assert d.kind == "subscription" and d.auth == "oauth"
# The signed-in key, always against OpenRouter's own endpoint.
assert d.build({"api_key": "account-key", "base_url": "https://untrusted.example"}, None) == {
"api_key": "account-key",
"base_url": "https://openrouter.ai/api/v1",
}
assert registry.descriptor_configured(d, {"api_key": "account-key"})
assert not registry.descriptor_configured(d, {})
with pytest.raises(RuntimeError, match="not signed in"):
d.build({}, None)
# The key-based card still resolves its own key and ignores the account's.
key_card = registry.get_descriptor("openrouter")
assert key_card.build({"api_key": "manual-key"}, None)["api_key"] == "manual-key"
assert key_card.build({}, None)["api_key"] == "environment-key"
def test_cancel_during_exchange_cannot_restore_account(tmp_path, monkeypatch):
store = SecretStore(tmp_path / "secrets.json")
auth = openrouter_auth.OpenRouterAuth(store)
async def run():
entered, release = asyncio.Event(), asyncio.Event()
async def exchange(code, verifier):
entered.set()
await release.wait()
return "late-secret"
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
started = await auth.start(manual=True)
pending = asyncio.create_task(auth.complete("code", started["attempt_id"]))
await entered.wait()
assert not auth.cancel()["authorizing"]
release.set()
await pending
assert not auth.status()["connected"]
asyncio.run(run())
assert store.get("provider:openrouter-account") is None
def test_replaced_attempt_cannot_complete(tmp_path, monkeypatch):
store = SecretStore(tmp_path / "secrets.json")
calls = []
async def exchange(code, verifier):
calls.append(code)
return "new-secret"
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
auth = openrouter_auth.OpenRouterAuth(store)
async def run():
first = await auth.start(manual=True)
second = await auth.start(manual=True)
assert first["attempt_id"] != second["attempt_id"]
await auth.complete("stale-code", first["attempt_id"])
assert calls == []
assert store.get("provider:openrouter-account") is None
await auth.complete("current-code", second["attempt_id"])
assert calls == ["current-code"]
asyncio.run(run())
def test_exchange_failure_preserves_previous_account(tmp_path, monkeypatch):
store = SecretStore(tmp_path / "secrets.json")
store.put("provider:openrouter-account", {"api_key": "previous-secret"})
async def exchange(code, verifier):
raise RuntimeError("remote response included sensitive-provider-data")
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
auth = openrouter_auth.OpenRouterAuth(store)
async def run():
started = await auth.start(manual=True)
result = await auth.complete("code", started["attempt_id"])
assert result["connected"]
assert result["error"]
assert "sensitive-provider-data" not in json.dumps(result)
asyncio.run(run())
assert store.get("provider:openrouter-account")["api_key"] == "previous-secret"
def test_disconnect_only_removes_account_and_rejects_pending_completion(
tmp_path, monkeypatch
):
store = SecretStore(tmp_path / "secrets.json")
store.put("provider:openrouter", {"api_key": "manual-secret"})
store.put("provider:openrouter-account", {"api_key": "account-secret"})
auth = openrouter_auth.OpenRouterAuth(store)
async def exchange(code, verifier):
raise AssertionError("Disconnected attempts must not exchange credentials")
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
async def run():
started = await auth.start(manual=True)
result = auth.disconnect()
assert not result["connected"] and not result["authorizing"]
await auth.complete("old-code", started["attempt_id"])
asyncio.run(run())
assert store.get("provider:openrouter-account") is None
assert store.get("provider:openrouter")["api_key"] == "manual-secret"
def test_exchange_validates_key_with_authenticated_endpoint(monkeypatch):
requests = []
def respond(request):
requests.append(request)
if request.method == "POST":
return httpx.Response(200, json={"key": "sk-or-test-secret"})
return httpx.Response(200, json={"data": {"label": "OpenWorker"}})
client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
monkeypatch.setattr(openrouter_auth.httpx, "AsyncClient", lambda **kwargs: client)
assert (
asyncio.run(openrouter_auth.exchange_key("code", "verifier"))
== "sk-or-test-secret"
)
assert len(requests) == 2
assert str(requests[0].url) == "https://openrouter.ai/api/v1/auth/keys"
assert json.loads(requests[0].content) == {
"code": "code",
"code_verifier": "verifier",
"code_challenge_method": "S256",
}
assert str(requests[1].url) == "https://openrouter.ai/api/v1/auth/key"
assert requests[1].headers["authorization"] == "Bearer sk-or-test-secret"
def test_loopback_callback_requires_attempt_specific_path(tmp_path, monkeypatch):
store = SecretStore(tmp_path / "secrets.json")
auth = openrouter_auth.OpenRouterAuth(store)
codes = []
async def exchange(code, verifier):
codes.append(code)
return "sk-or-callback-secret"
monkeypatch.setattr(openrouter_auth, "exchange_key", exchange)
async def run():
started = await auth.start()
callback = parse_qs(urlsplit(started["authorize_url"]).query)["callback_url"][0]
address = urlsplit(callback)
assert address.hostname in {"127.0.0.1", "localhost"}
try:
async with httpx.AsyncClient() as client:
wrong = await client.get(f"http://{address.netloc}/wrong?code=bad")
assert wrong.status_code == 400
assert codes == []
correct = await client.get(callback, params={"code": "valid-code"})
assert correct.status_code == 200
assert "sk-or-callback-secret" not in correct.text
assert codes == ["valid-code"]
assert auth.status()["connected"]
finally:
auth.cancel()
asyncio.run(run())