From b218b74be8692872b05b839463678ba87db3d9ee Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 4 Oct 2026 21:48:56 +0200 Subject: [PATCH] DRU-85 -- Connect OpenAI subscriptions with Codex device authorization --- backend/druks/harnesses/providers.py | 111 ++++++++++++++---- backend/druks/harnesses/routes.py | 55 ++++++++- backend/druks/harnesses/schemas.py | 10 +- backend/tests/test_auth_boundary.py | 2 + backend/tests/test_identity.py | 37 ++++-- backend/tests/test_provider_connect.py | 109 ++++++++++++----- docs/configuration.md | 4 + frontend/src/api/client.ts | 5 + frontend/src/api/types.ts | 17 ++- .../src/components/IdentityBootstrap.test.tsx | 2 +- frontend/src/components/Onboarding.test.tsx | 2 +- frontend/src/components/Onboarding.tsx | 4 +- .../src/components/ProviderConnect.test.tsx | 2 + .../components/ProviderConnectFlow.test.tsx | 92 +++++++++++++++ .../src/components/ProviderConnectFlow.tsx | 74 +++++++++++- 15 files changed, 442 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/ProviderConnectFlow.test.tsx diff --git a/backend/druks/harnesses/providers.py b/backend/druks/harnesses/providers.py index 747b71e28..6a6542c9b 100644 --- a/backend/druks/harnesses/providers.py +++ b/backend/druks/harnesses/providers.py @@ -64,6 +64,8 @@ # long — enough to authorize and paste, short enough that an abandoned attempt # clears. _CONNECT_PENDING_TTL_SECONDS = 600 +# OpenAI expires a device code after 15 minutes. +_DEVICE_CODE_TTL_SECONDS = 15 * 60 # How often a fetch asks again while another refresher holds the row lock. _LOCK_POLL_SECONDS = 0.5 @@ -92,9 +94,9 @@ def get_secret(cls, key: str) -> Secret: raise NotImplementedError @classmethod - async def connect_start(cls, *, account_id: str | None = None) -> tuple[str, str]: - """Mint PKCE state under a single-use flow id; return (authorize URL, - flow id). A flow started by a resolved operator binds ``account_id``.""" + async def start_connection(cls, *, account_id: str | None = None) -> dict: + """Mint PKCE state under a single-use flow id; return the challenge the + operator answers. A flow started by a resolved operator binds ``account_id``.""" verifier = _b64url(secrets.token_bytes(64)) challenge = _b64url(hashlib.sha256(verifier.encode()).digest()) url, state = cls.authorize_url(verifier=verifier, challenge=challenge) @@ -109,10 +111,10 @@ async def connect_start(cls, *, account_id: str | None = None) -> tuple[str, str await get_client().set( f"{CONNECT_PENDING_PREFIX}{flow_id}", pending, ex=_CONNECT_PENDING_TTL_SECONDS ) - return url, flow_id + return {"method": "code", "connection_id": flow_id, "authorize_url": url} @classmethod - async def connect_complete(cls, *, flow_id: str, pasted: str) -> CompletedConnect: + async def complete_connection(cls, *, flow_id: str, pasted: str) -> CompletedConnect: """Pop the flow's single-use state, parse the paste, exchange the code. Raises :class:`ConnectError` on failure; the state is gone either way, so a retry re-starts cleanly.""" @@ -129,7 +131,20 @@ async def connect_complete(cls, *, flow_id: str, pasted: str) -> CompletedConnec "That code is from a different connect attempt — start it again." ) - payload, provider_email = await cls.exchange(code=code, verifier=expected["verifier"]) + return await cls._complete( + code=code, verifier=expected["verifier"], account_id=expected["account_id"] + ) + + @classmethod + async def check_connection(cls, *, flow_id: str) -> CompletedConnect | None: + """The connect of a device flow after the operator approves it. None until then.""" + raise exceptions.ConnectError(f"{cls.label} does not connect with a device code.") + + @classmethod + async def _complete( + cls, *, code: str, verifier: str, account_id: str | None + ) -> CompletedConnect: + payload, provider_email = await cls.exchange(code=code, verifier=verifier) if not provider_email: raise exceptions.ConnectError( "The provider returned no account email — authorize with an account " @@ -140,13 +155,13 @@ async def connect_complete(cls, *, flow_id: str, pasted: str) -> CompletedConnec payload=payload, provider_email=provider_email, expires_at=expires_at, - account_id=expected["account_id"], + account_id=account_id, ) @classmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: """Build this provider's PKCE authorize URL; return (url, state), where - ``state`` is what the provider echoes back so connect_complete can + ``state`` is what the provider echoes back so complete_connection can verify the round-trip.""" raise NotImplementedError @@ -735,7 +750,7 @@ def _grant_body(cls, refresh_token: str) -> dict: @classmethod def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: # Anthropic's console flow echoes the PKCE verifier back as the OAuth - # state, so that's what connect_complete checks. + # state, so that's what complete_connection checks. params = { "code": "true", "client_id": cls._CLIENT_ID, @@ -923,9 +938,9 @@ class OpenAiProvider(Provider): REFRESH_MARGIN = timedelta(hours=24) _TOKEN_URL = "https://auth.openai.com/oauth/token" _CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" - # Connect-flow (PKCE): authorize on auth.openai.com; the operator pastes the - # failed localhost redirect URL back. - redirect_uri = "http://localhost:1455/auth/callback" + # Connect-flow (device code): the operator enters a code at auth.openai.com, + # and Druks polls for the grant. + redirect_uri = "https://auth.openai.com/deviceauth/callback" # This additional limit is a reserve quota, not a model's own quota. reserve_limit_name = "gpt-reserve" @@ -968,21 +983,65 @@ def _grant_body(cls, refresh_token: str) -> dict: } @classmethod - def authorize_url(cls, *, verifier: str, challenge: str) -> tuple[str, str]: - state = secrets.token_hex(16) - params = { - "id_token_add_organizations": "true", - "codex_cli_simplified_flow": "true", - "originator": "pi", # the only value verified against the live exchange - "client_id": cls._CLIENT_ID, - "response_type": "code", - "redirect_uri": cls.redirect_uri, - "scope": "openid profile email offline_access", - "code_challenge": challenge, - "code_challenge_method": "S256", - "state": state, + async def start_connection(cls, *, account_id: str | None = None) -> dict: + device = await post_token( + url="https://auth.openai.com/api/accounts/deviceauth/usercode", + body={"client_id": cls._CLIENT_ID}, + form=False, + ) + flow_id = secrets.token_urlsafe(24) + pending = json.dumps( + { + "device_auth_id": device["device_auth_id"], + "user_code": device["user_code"], + "account_id": account_id, + } + ) + await get_client().set( + name=f"{CONNECT_PENDING_PREFIX}{flow_id}", value=pending, ex=_DEVICE_CODE_TTL_SECONDS + ) + return { + "method": "device", + "connection_id": flow_id, + "authorize_url": "https://auth.openai.com/codex/device", + "user_code": device["user_code"], + "poll_interval": int(device["interval"]), } - return f"https://auth.openai.com/oauth/authorize?{urlencode(params)}", state + + @classmethod + async def check_connection(cls, *, flow_id: str) -> CompletedConnect | None: + key = f"{CONNECT_PENDING_PREFIX}{flow_id}" + pending = await get_client().get(key) + if not pending: + raise exceptions.ConnectError("This connect attempt expired. Start it again.") + device = json.loads(pending) + + try: + async with httpx.AsyncClient(timeout=_TOKEN_REQUEST_TIMEOUT_SECONDS) as client: + response = await client.post( + "https://auth.openai.com/api/accounts/deviceauth/token", + json={ + "device_auth_id": device["device_auth_id"], + "user_code": device["user_code"], + }, + ) + except httpx.HTTPError as error: + raise exceptions.ConnectError("The request to OpenAI failed. Try again.") from error + # OpenAI answers 403 or 404 until the operator approves. + if response.status_code in (403, 404): + return + if response.status_code != 200: + raise exceptions.ConnectError( + f"OpenAI rejected the device code (HTTP {response.status_code}). Try again." + ) + + await get_client().delete(key) + approval = response.json() + return await cls._complete( + code=approval["authorization_code"], + verifier=approval["code_verifier"], + account_id=device["account_id"], + ) @classmethod async def exchange(cls, *, code: str, verifier: str) -> tuple[dict, str | None]: diff --git a/backend/druks/harnesses/routes.py b/backend/druks/harnesses/routes.py index a8b46c0c8..d3d9e0315 100644 --- a/backend/druks/harnesses/routes.py +++ b/backend/druks/harnesses/routes.py @@ -2,6 +2,7 @@ from contextlib import suppress from fastapi import APIRouter, Body, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession from druks.accounts.dependencies import current_session_account, current_session_or_setup from druks.accounts.models import Account @@ -12,10 +13,12 @@ from druks.secrets.models import VaultSecret from . import directory +from .datastructures import CompletedConnect from .exceptions import ConnectError from .models import ProviderCatalog from .providers import Provider, get_provider, get_providers, is_registered from .schemas import ( + ConnectChallengeResponse, ProviderCatalogResponse, ProviderDirectoryResponse, ProviderKeyResponse, @@ -89,14 +92,21 @@ def _resolve_provider(provider_id: str) -> Provider: raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_id!r}") from error -@router.post("/{provider_id}/connection/start") +@router.post( + "/{provider_id}/connection/start", + response_model=ConnectChallengeResponse, + response_model_by_alias=True, + response_model_exclude_none=True, +) async def start_connection( provider_id: str, account: Account | None = Depends(current_session_or_setup) -) -> dict[str, str]: +) -> dict: provider = _resolve_provider(provider_id) - # In none/zero the flow starts unbound, and its completion creates the operator. - url, flow_id = await provider.connect_start(account_id=account.id if account else None) - return {"authorizeUrl": url, "connectionId": flow_id} + try: + # In none/zero the flow starts unbound, and its completion creates the operator. + return await provider.start_connection(account_id=account.id if account else None) + except ConnectError as error: + raise HTTPException(status_code=422, detail=str(error)) from error @router.post( @@ -113,9 +123,42 @@ async def complete_connection( ) -> AccountResponse: provider = _resolve_provider(provider_id) try: - completed = await provider.connect_complete(flow_id=flow_id, pasted=code) + completed = await provider.complete_connection(flow_id=flow_id, pasted=code) except ConnectError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc + return await _store_connection(session, provider=provider, completed=completed, account=account) + + +@router.post( + "/{provider_id}/connection/check", + response_model=AccountResponse | None, + response_model_by_alias=True, +) +async def check_connection( + session: SessionDep, + provider_id: str, + account: Account | None = Depends(current_session_or_setup), + flow_id: str = Body(..., embed=True, alias="connectionId"), +) -> AccountResponse | None: + """The connected account after the operator approves. None until then.""" + provider = _resolve_provider(provider_id) + try: + completed = await provider.check_connection(flow_id=flow_id) + except ConnectError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + if completed: + return await _store_connection( + session, provider=provider, completed=completed, account=account + ) + + +async def _store_connection( + session: AsyncSession, + *, + provider: Provider, + completed: CompletedConnect, + account: Account | None, +) -> AccountResponse: if account and account.id == completed.account_id: resolved = account elif completed.account_id: diff --git a/backend/druks/harnesses/schemas.py b/backend/druks/harnesses/schemas.py index 5b0562056..66cc5206f 100644 --- a/backend/druks/harnesses/schemas.py +++ b/backend/druks/harnesses/schemas.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated +from typing import Annotated, Literal from pydantic import AliasPath, BeforeValidator, ConfigDict, Field @@ -17,6 +17,14 @@ class ProviderResponse(Schema): billing_options: SortedNames +class ConnectChallengeResponse(Schema): + method: Literal["code", "device"] + connection_id: str + authorize_url: str + user_code: str | None = None + poll_interval: int | None = None + + class ProviderSubscriptionResponse(Schema): model_config = ConfigDict(from_attributes=True) diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index ba3dff648..e02cbfacc 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -16,6 +16,7 @@ "/api/providers", "/api/providers/{provider_id}/connection/start", "/api/providers/{provider_id}/connection/complete", + "/api/providers/{provider_id}/connection/check", "/api/secrets/{identity_id}/{name}", # a box's identity bearer, nothing else "/api/{path:path}", # the JSON-404 catch-all } @@ -105,6 +106,7 @@ "/api/providers", "/api/providers/{provider_id}/connection/start", "/api/providers/{provider_id}/connection/complete", + "/api/providers/{provider_id}/connection/check", } diff --git a/backend/tests/test_identity.py b/backend/tests/test_identity.py index 06206fa15..1d5b29982 100644 --- a/backend/tests/test_identity.py +++ b/backend/tests/test_identity.py @@ -49,12 +49,18 @@ def _connect( email: str = "me@example.com", headers: dict[str, str] | None = None, ): + if provider == "openai": + _mock_exchange_codex(monkeypatch, email=email) start = client.post(f"/api/providers/{provider}/connection/start", headers=headers) assert start.status_code == 200 if provider == "anthropic": _mock_exchange(monkeypatch, _grant(email)) else: - _mock_exchange_codex(monkeypatch, email=email) + return client.post( + f"/api/providers/{provider}/connection/check", + json={"connectionId": start.json()["connectionId"]}, + headers=headers, + ) return client.post( f"/api/providers/{provider}/connection/complete", json={"code": "thecode", "connectionId": start.json()["connectionId"]}, @@ -70,10 +76,21 @@ def _mock_exchange_codex(monkeypatch, *, email: str): } header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() - _mock_exchange( - monkeypatch, - {"access_token": f"{header}.{payload}.sig", "refresh_token": "RT", "id_token": "ID"}, - ) + + async def fake_post(self, url, **_kwargs): + if url.endswith("/usercode"): + grant = {"device_auth_id": "device-id", "user_code": "ABCD-EFGH", "interval": "1"} + elif url.endswith("/deviceauth/token"): + grant = {"authorization_code": "approved", "code_verifier": "verifier"} + else: + grant = { + "access_token": f"{header}.{payload}.sig", + "refresh_token": "RT", + "id_token": "ID", + } + return httpx.Response(200, json=grant, request=httpx.Request("POST", url)) + + monkeypatch.setattr(providers.httpx.AsyncClient, "post", fake_post) async def test_header_mode_requires_exactly_one_nonblank_assertion(tmp_path, druks_db): @@ -292,6 +309,7 @@ async def test_concurrent_setup_completions_with_one_email_converge( with _client(tmp_path) as client: # No account exists yet, so both flows start unbound. first = client.post("/api/providers/anthropic/connection/start") + _mock_exchange_codex(monkeypatch, email="me@example.com") second = client.post("/api/providers/openai/connection/start") _mock_exchange(monkeypatch, _grant("me@example.com")) assert ( @@ -304,8 +322,8 @@ async def test_concurrent_setup_completions_with_one_email_converge( _mock_exchange_codex(monkeypatch, email="me@example.com") assert ( client.post( - "/api/providers/openai/connection/complete", - json={"code": "c2", "connectionId": second.json()["connectionId"]}, + "/api/providers/openai/connection/check", + json={"connectionId": second.json()["connectionId"]}, ).status_code == 200 ) @@ -318,6 +336,7 @@ async def test_a_stale_unbound_completion_attaches_to_the_operator(tmp_path, mon # The first completion creates the operator. The second, with another # provider email, attaches to it instead of creating a second account. first = client.post("/api/providers/anthropic/connection/start") + _mock_exchange_codex(monkeypatch, email="me@example.com") second = client.post("/api/providers/openai/connection/start") _mock_exchange(monkeypatch, _grant("a@example.com")) client.post( @@ -326,8 +345,8 @@ async def test_a_stale_unbound_completion_attaches_to_the_operator(tmp_path, mon ) _mock_exchange_codex(monkeypatch, email="b@example.com") completed = client.post( - "/api/providers/openai/connection/complete", - json={"code": "c2", "connectionId": second.json()["connectionId"]}, + "/api/providers/openai/connection/check", + json={"connectionId": second.json()["connectionId"]}, ) assert completed.status_code == 200 assert completed.json()["username"] == "a@example.com" diff --git a/backend/tests/test_provider_connect.py b/backend/tests/test_provider_connect.py index ca1c895ff..b2834ce1e 100644 --- a/backend/tests/test_provider_connect.py +++ b/backend/tests/test_provider_connect.py @@ -21,12 +21,13 @@ def _resp(status: int, body: object) -> httpx.Response: return httpx.Response(status, text=text, request=httpx.Request("POST", "https://x")) -def _mock_post(monkeypatch, response): +def _mock_post(monkeypatch, *responses): calls = [] async def fake_post(self, url, *, json=None, data=None, **_kwargs): calls.append({"url": url, "json": json, "data": data}) - return response + # The last response answers every later call. + return responses[min(len(calls), len(responses)) - 1] monkeypatch.setattr(pbase.httpx.AsyncClient, "post", fake_post) return calls @@ -47,7 +48,8 @@ async def _pending(flow_id: str) -> dict | None: async def test_claude_connect_start_builds_url_and_stashes_pending(druks_db): - url, flow_id = await AnthropicProvider.connect_start() + challenge = await AnthropicProvider.start_connection() + url, flow_id = challenge["authorize_url"], challenge["connection_id"] assert url.startswith("https://claude.ai/oauth/authorize?") assert "code=true" in url assert "code_challenge_method=S256" in url @@ -60,15 +62,15 @@ async def test_claude_connect_start_builds_url_and_stashes_pending(druks_db): async def test_connect_start_binds_the_operator_account(druks_db): - _, flow_id = await AnthropicProvider.connect_start(account_id="acct-1") + flow_id = (await AnthropicProvider.start_connection(account_id="acct-1"))["connection_id"] pending = await _pending(flow_id) assert pending["account_id"] == "acct-1" async def test_claude_connect_complete_returns_the_exchange(monkeypatch, druks_db): - _, flow_id = await AnthropicProvider.connect_start(account_id="acct-1") + flow_id = (await AnthropicProvider.start_connection(account_id="acct-1"))["connection_id"] calls = _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - completed = await AnthropicProvider.connect_complete(flow_id=flow_id, pasted="thecode") + completed = await AnthropicProvider.complete_connection(flow_id=flow_id, pasted="thecode") block = completed.payload["claudeAiOauth"] assert block["accessToken"] == "AT" @@ -88,33 +90,62 @@ async def test_claude_connect_complete_returns_the_exchange(monkeypatch, druks_d async def test_concurrent_connect_flows_do_not_clobber_each_other(monkeypatch, druks_db): # Two people connect the same provider at once: distinct flow ids, both # pendings live, and completing one leaves the other completable. - _, first_flow = await AnthropicProvider.connect_start() - _, second_flow = await AnthropicProvider.connect_start() + first_flow = (await AnthropicProvider.start_connection())["connection_id"] + second_flow = (await AnthropicProvider.start_connection())["connection_id"] assert first_flow != second_flow _mock_post(monkeypatch, _resp(200, _CLAUDE_GRANT)) - first = await AnthropicProvider.connect_complete(flow_id=first_flow, pasted="code-1") + first = await AnthropicProvider.complete_connection(flow_id=first_flow, pasted="code-1") assert await _pending(second_flow) second_grant = dict(_CLAUDE_GRANT, account={"email_address": "other@example.com"}) _mock_post(monkeypatch, _resp(200, second_grant)) - second = await AnthropicProvider.connect_complete(flow_id=second_flow, pasted="code-2") + second = await AnthropicProvider.complete_connection(flow_id=second_flow, pasted="code-2") assert first.provider_email == "me@example.com" assert second.provider_email == "other@example.com" async def test_connect_complete_without_provider_email_raises(monkeypatch, druks_db): - _, flow_id = await AnthropicProvider.connect_start() + flow_id = (await AnthropicProvider.start_connection())["connection_id"] grant = dict(_CLAUDE_GRANT, account={}) _mock_post(monkeypatch, _resp(200, grant)) with pytest.raises(ConnectError, match="no account email"): - await AnthropicProvider.connect_complete(flow_id=flow_id, pasted="thecode") + await AnthropicProvider.complete_connection(flow_id=flow_id, pasted="thecode") -async def test_codex_connect_complete_is_form_encoded_and_reads_jwt(monkeypatch, druks_db): - _, flow_id = await OpenAiProvider.connect_start() - pending = await _pending(flow_id) +_DEVICE_CODE = {"device_auth_id": "device-1", "user_code": "ABCD-EFGH", "interval": "5"} + + +async def test_codex_connect_start_requests_a_device_code(monkeypatch, druks_db): + calls = _mock_post(monkeypatch, _resp(200, _DEVICE_CODE)) + challenge = await OpenAiProvider.start_connection(account_id="acct-1") + + assert calls[0]["json"] == {"client_id": OpenAiProvider._CLIENT_ID} + assert challenge == { + "method": "device", + "connection_id": challenge["connection_id"], + "authorize_url": "https://auth.openai.com/codex/device", + "user_code": "ABCD-EFGH", + "poll_interval": 5, + } + assert await _pending(challenge["connection_id"]) == { + "device_auth_id": "device-1", + "user_code": "ABCD-EFGH", + "account_id": "acct-1", + } + + +@pytest.mark.parametrize("status", [403, 404]) +async def test_codex_connect_check_waits_for_approval(monkeypatch, druks_db, status): + _mock_post(monkeypatch, _resp(200, _DEVICE_CODE), _resp(status, "")) + flow_id = (await OpenAiProvider.start_connection())["connection_id"] + + assert not await OpenAiProvider.check_connection(flow_id=flow_id) + assert await _pending(flow_id) + + +async def test_codex_connect_check_exchanges_the_approved_code(monkeypatch, druks_db): access = _jwt( { "https://api.openai.com/auth": {"chatgpt_account_id": "acc-9"}, @@ -123,49 +154,67 @@ async def test_codex_connect_complete_is_form_encoded_and_reads_jwt(monkeypatch, } ) calls = _mock_post( - monkeypatch, _resp(200, {"access_token": access, "refresh_token": "RT", "id_token": "ID"}) - ) - completed = await OpenAiProvider.connect_complete( - flow_id=flow_id, - pasted=f"http://localhost:1455/auth/callback?code=thecode&state={pending['state']}", + monkeypatch, + _resp(200, _DEVICE_CODE), + _resp(200, {"authorization_code": "thecode", "code_verifier": "theverifier"}), + _resp(200, {"access_token": access, "refresh_token": "RT", "id_token": "ID"}), ) + flow_id = (await OpenAiProvider.start_connection(account_id="acct-1"))["connection_id"] + completed = await OpenAiProvider.check_connection(flow_id=flow_id) + assert calls[1]["json"] == {"device_auth_id": "device-1", "user_code": "ABCD-EFGH"} assert completed.payload["tokens"]["account_id"] == "acc-9" assert completed.payload["tokens"]["id_token"] == "ID" assert completed.provider_email == "c@example.com" - # Codex exchanges form-encoded, no state in the body. - assert calls[0]["data"]["code"] == "thecode" - assert "state" not in calls[0]["data"] + assert completed.account_id == "acct-1" + # Codex exchanges form-encoded, with the verifier OpenAI returned. + assert calls[2]["data"]["code"] == "thecode" + assert calls[2]["data"]["code_verifier"] == "theverifier" + assert calls[2]["data"]["redirect_uri"] == "https://auth.openai.com/deviceauth/callback" + # The attempt is single-use. + assert not await _pending(flow_id) + + +async def test_codex_connect_check_rejected_device_code_raises(monkeypatch, druks_db): + _mock_post(monkeypatch, _resp(200, _DEVICE_CODE), _resp(400, "expired")) + flow_id = (await OpenAiProvider.start_connection())["connection_id"] + with pytest.raises(ConnectError, match="HTTP 400"): + await OpenAiProvider.check_connection(flow_id=flow_id) + + +async def test_codex_connect_check_without_pending_raises(druks_db): + with pytest.raises(ConnectError, match="expired"): + await OpenAiProvider.check_connection(flow_id="not-a-flow") async def test_connect_complete_unreadable_provider_json_raises_connect_error( monkeypatch, druks_db ): - _, flow_id = await AnthropicProvider.connect_start() + flow_id = (await AnthropicProvider.start_connection())["connection_id"] _mock_post(monkeypatch, _resp(200, "not json")) with pytest.raises(ConnectError) as error: - await AnthropicProvider.connect_complete(flow_id=flow_id, pasted="code") + await AnthropicProvider.complete_connection(flow_id=flow_id, pasted="code") assert "unreadable response" in str(error.value) async def test_connect_complete_without_pending_raises(druks_db): with pytest.raises(ConnectError): - await AnthropicProvider.connect_complete(flow_id="not-a-flow", pasted="code") + await AnthropicProvider.complete_connection(flow_id="not-a-flow", pasted="code") async def test_connect_complete_state_mismatch_raises(druks_db): - _, flow_id = await AnthropicProvider.connect_start() + flow_id = (await AnthropicProvider.start_connection())["connection_id"] with pytest.raises(ConnectError): - await AnthropicProvider.connect_complete(flow_id=flow_id, pasted="code#not-the-state") + await AnthropicProvider.complete_connection(flow_id=flow_id, pasted="code#not-the-state") async def test_connect_complete_provider_error_clears_pending(monkeypatch, druks_db): - _, flow_id = await AnthropicProvider.connect_start() + flow_id = (await AnthropicProvider.start_connection())["connection_id"] _mock_post(monkeypatch, _resp(400, "invalid_grant: code expired")) with pytest.raises(ConnectError) as error: - await AnthropicProvider.connect_complete(flow_id=flow_id, pasted="code") + await AnthropicProvider.complete_connection(flow_id=flow_id, pasted="code") assert "invalid_grant" in str(error.value) # Failure is single-use too — a retry must re-start. assert not await _pending(flow_id) diff --git a/docs/configuration.md b/docs/configuration.md index d6f230a19..ee09a03eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -619,6 +619,10 @@ requesting account. In a fresh `none`-mode install, the first completed subscription connection also creates the operator account. See [access control](#public-urls-and-access-control). +OpenAI connects through Codex device authorization. Before you connect, open +**Settings → Security** in ChatGPT and enable **device code authorization for +Codex**. In a managed workspace, an administrator must allow it. + A `claude` sandbox reads its placeholder from `ANTHROPIC_AUTH_TOKEN`. A `codex` sandbox reads its placeholder from `CODEX_SUBSCRIPTION_TOKEN`. The Codex run wrapper writes `~/.codex/auth.json` from that variable before the diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index db4cd26f0..47b3760eb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -315,6 +315,11 @@ export const api = { code, connectionId, }), + // The connected account after the operator approves a device code; null until then. + checkProviderConnect: (id: string, connectionId: string) => + postJSON(`/api/providers/${encodeURIComponent(id)}/connection/check`, { + connectionId, + }), createProviderKey: (id: string, key: string) => postJSON(`/api/providers/${encodeURIComponent(id)}/key`, { key }), removeProviderKey: (id: string) => deleteRequest(`/api/providers/${encodeURIComponent(id)}/key`), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 9a2302ea7..2c11de1c4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -636,12 +636,17 @@ export interface Identity { onboardingRequired: boolean } -export interface ConnectChallenge { - authorizeUrl: string - /** Opaque id of this connect attempt; passed back on complete so - * concurrent connects never clobber each other's pending state. */ - connectionId: string -} +/** connectionId is the opaque id of this connect attempt; passed back so + * concurrent connects never clobber each other's pending state. */ +export type ConnectChallenge = + | { method: 'code'; connectionId: string; authorizeUrl: string } + | { + method: 'device' + connectionId: string + authorizeUrl: string + userCode: string + pollInterval: number + } export interface ServiceField { name: string diff --git a/frontend/src/components/IdentityBootstrap.test.tsx b/frontend/src/components/IdentityBootstrap.test.tsx index 53457a4eb..949265457 100644 --- a/frontend/src/components/IdentityBootstrap.test.tsx +++ b/frontend/src/components/IdentityBootstrap.test.tsx @@ -95,7 +95,7 @@ describe('IdentityBootstrap', () => { }), '/api/providers/anthropic/connection/start': () => ({ status: 200, - body: { authorizeUrl: 'https://x/auth', connectionId: 'C1' }, + body: { method: 'code', authorizeUrl: 'https://x/auth', connectionId: 'C1' }, }), '/api/providers/anthropic/connection/complete': () => { // The completed connection created the operator; /me now resolves it. diff --git a/frontend/src/components/Onboarding.test.tsx b/frontend/src/components/Onboarding.test.tsx index e05cffe91..a18311189 100644 --- a/frontend/src/components/Onboarding.test.tsx +++ b/frontend/src/components/Onboarding.test.tsx @@ -51,7 +51,7 @@ describe('Onboarding', () => { if (path === '/api/providers') return providerListResponse(REGISTERED_PROVIDERS) expect(path).toBe('/api/providers/openai/connection/start') return new Response( - JSON.stringify({ authorizeUrl: 'https://x/auth', connectionId: 'C1' }), + JSON.stringify({ method: 'code', authorizeUrl: 'https://x/auth', connectionId: 'C1' }), { status: 200 }, ) }), diff --git a/frontend/src/components/Onboarding.tsx b/frontend/src/components/Onboarding.tsx index 0a3b91e25..5950ce52d 100644 --- a/frontend/src/components/Onboarding.tsx +++ b/frontend/src/components/Onboarding.tsx @@ -130,7 +130,9 @@ function ConnectPanel({ entry }: { entry: OnboardingEntry }) { {entry.mark} Connect {entry.title} - oauth · paste-back + + {flow.challenge?.method === 'device' ? 'oauth · device code' : 'oauth · paste-back'} + {flow.challenge ? 'authorize' : 'connecting'} diff --git a/frontend/src/components/ProviderConnect.test.tsx b/frontend/src/components/ProviderConnect.test.tsx index 23b11d57f..f7d81f184 100644 --- a/frontend/src/components/ProviderConnect.test.tsx +++ b/frontend/src/components/ProviderConnect.test.tsx @@ -238,6 +238,7 @@ describe('ProviderConnect', () => { it('a provider revocation keeps its facts and starts reconnect', async () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + method: 'code', authorizeUrl: 'https://example.com/authorize', connectionId: 'reconnect-1', }))) @@ -286,6 +287,7 @@ describe('ProviderConnect', () => { it('drives the connection flow and refreshes credentials, models, and usage', async () => { const responses: Record = { '/api/providers/anthropic/connection/start': { + method: 'code', authorizeUrl: 'https://x/auth', connectionId: 'C1', }, diff --git a/frontend/src/components/ProviderConnectFlow.test.tsx b/frontend/src/components/ProviderConnectFlow.test.tsx new file mode 100644 index 000000000..e4ef0905d --- /dev/null +++ b/frontend/src/components/ProviderConnectFlow.test.tsx @@ -0,0 +1,92 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { api } from '../api/client' +import { Onboarding } from './Onboarding' + +const account = { id: 'operator', username: 'operator@example.com', isDefault: true } +const onConnected = vi.fn() + +beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(api, 'providers').mockResolvedValue([ + { id: 'openai', label: 'OpenAI', billingOptions: ['subscription', 'api_key'] }, + ]) + vi.spyOn(api, 'startProviderConnect').mockResolvedValue({ + method: 'device', + connectionId: 'attempt-1', + authorizeUrl: 'https://auth.openai.com/codex/device', + userCode: 'ABCD-EFGH', + pollInterval: 5, + }) +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + onConnected.mockReset() +}) + +async function connect() { + render() + await act(async () => {}) + fireEvent.click(screen.getByRole('button', { name: /Connect OpenAI/ })) + await act(async () => {}) +} + +async function advance(milliseconds = 5000) { + await act(async () => { await vi.advanceTimersByTimeAsync(milliseconds) }) +} + +it('shows the device code and completes onboarding after approval', async () => { + const check = vi.spyOn(api, 'checkProviderConnect') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(account) + await connect() + + expect(screen.getByText('ABCD-EFGH')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Open OpenAI' }).getAttribute('href')) + .toBe('https://auth.openai.com/codex/device') + expect(screen.queryByPlaceholderText('Paste the code or redirect URL')).toBeNull() + expect(screen.getByRole('status').textContent).toContain('Waiting for approval') + expect(check).not.toHaveBeenCalled() + await advance() + expect(onConnected).not.toHaveBeenCalled() + await advance() + + expect(check).toHaveBeenNthCalledWith(1, 'openai', 'attempt-1') + expect(onConnected).toHaveBeenCalledExactlyOnceWith(account) + await advance(30000) + expect(check).toHaveBeenCalledTimes(2) +}) + +it('shows a failed check and starts a new attempt on retry', async () => { + const check = vi.spyOn(api, 'checkProviderConnect') + .mockRejectedValueOnce(new Error('OpenAI rejected the device code (HTTP 400). Try again.')) + .mockResolvedValue(account) + await connect() + await advance() + + expect(screen.getByText('OpenAI rejected the device code (HTTP 400). Try again.')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + await advance(30000) + expect(check).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + await act(async () => {}) + expect(api.startProviderConnect).toHaveBeenCalledTimes(2) + await advance() + expect(onConnected).toHaveBeenCalledExactlyOnceWith(account) +}) + +it('stops polling on cancel', async () => { + const check = vi.spyOn(api, 'checkProviderConnect').mockResolvedValue(null) + await connect() + await advance() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await advance(30000) + + expect(check).toHaveBeenCalledTimes(1) + expect(onConnected).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /Connect OpenAI/ })).toBeTruthy() +}) diff --git a/frontend/src/components/ProviderConnectFlow.tsx b/frontend/src/components/ProviderConnectFlow.tsx index 009ec4331..32373804f 100644 --- a/frontend/src/components/ProviderConnectFlow.tsx +++ b/frontend/src/components/ProviderConnectFlow.tsx @@ -1,9 +1,8 @@ -import { useState } from 'react' +import { useEffect, useEffectEvent, useState } from 'react' import { api } from '../api/client' import type { Account, ConnectChallenge } from '../api/types' -// The one PKCE paste-back connect flow, shared by onboarding and Settings. // eslint-disable-next-line react-refresh/only-export-components -- hook co-located with its steps UI export function useProviderConnect( id: string, @@ -13,6 +12,35 @@ export function useProviderConnect( const [code, setCode] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const onConnected = useEffectEvent(onDone) + + useEffect(() => { + if (challenge?.method !== 'device') return + const { connectionId, pollInterval } = challenge + let stopped = false + let timer: ReturnType + + async function poll() { + try { + const account = await api.checkProviderConnect(id, connectionId) + if (stopped) return + if (account) { + setChallenge(null) + await onConnected(account) + } else { + timer = setTimeout(() => void poll(), pollInterval * 1000) + } + } catch (e) { + if (!stopped) setError(e instanceof Error ? e.message : String(e)) + } + } + + timer = setTimeout(() => void poll(), pollInterval * 1000) + return () => { + stopped = true + clearTimeout(timer) + } + }, [challenge, id]) async function run(action: () => Promise) { setBusy(true) @@ -26,7 +54,11 @@ export function useProviderConnect( } } - const start = () => run(async () => setChallenge(await api.startProviderConnect(id))) + const start = () => + run(async () => { + setChallenge(null) + setChallenge(await api.startProviderConnect(id)) + }) const finish = () => run(async () => { @@ -52,6 +84,42 @@ export function ConnectSteps({ flow: ReturnType }) { if (!flow.challenge) return null + if (flow.challenge.method === 'device') { + const challenge = flow.challenge + return ( +
+

+ In ChatGPT, open Settings → Security and enable device code authorization for Codex. +

+
+ 1 + Your code: {challenge.userCode} + +
+
+ 2 + + + Open OpenAI + + , enter the code, and approve. + +
+ {flow.error ? ( + + ) : ( +

Waiting for approval… Approve within 15 minutes.

+ )} +
+ ) + } return (