diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index ffc5f582b..0f35bf0d6 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -683,7 +683,7 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s Direct API-key sessions intentionally have no ``ModelEndpoint`` row and retain their documented compatibility behavior. Registered endpoint sessions, including provider-auth-backed rows, must use the visible - server-owned inventory and never trigger a provider lookup here. + server-owned inventory and never trigger a live provider lookup here. """ # Lightweight in-memory test doubles from older route tests do not carry # durable provenance fields. They cannot represent a persisted bearer @@ -737,9 +737,9 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s validated = _validate_bearer_model_selection(ep, requested) # A session may outlive an endpoint-key rotation. For bearer calls, - # use the current static key for this exact endpoint and never trust a - # stale persisted Authorization header. Provider-auth rows remain - # request-local and are intentionally empty in cache-only mode. + # use the current exact-endpoint credentials and never trust a stale + # persisted Authorization header. Provider-auth credentials are + # owner-scoped, request-local, and cache-only in this boundary. try: from src.endpoint_resolver import build_headers, resolve_endpoint_runtime @@ -752,6 +752,9 @@ def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[s except Exception as exc: logger.warning("Could not refresh bearer session endpoint auth: %s", exc) sess.headers = {} + if getattr(ep, "provider_auth_id", None): + raise HTTPException(401, "Registered provider credentials are unavailable") from exc + raise HTTPException(400, "Registered endpoint credentials are unavailable") from exc sess.model = validated return validated diff --git a/routes/session_routes.py b/routes/session_routes.py index a31747f3f..7326fb9ff 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -997,6 +997,14 @@ def setup_session_routes( rag: str = Form(None) ): require_chat_scope(request) + # This legacy alias uses the server-owned OPENAI_API_KEY rather than a + # caller-selected, owner-visible ModelEndpoint. A bearer must not turn + # that credential into an owner-attributed session whose provenance + # cannot be represented as either a registered endpoint or a direct + # caller-supplied key. Registered bearer chat remains available through + # POST /api/session with endpoint_id. + if is_bearer_principal(request): + raise HTTPException(403, "Bearer callers must choose a registered model endpoint") if not OPENAI_API_KEY: raise HTTPException(400, "Server missing OPENAI_API_KEY") sid = str(uuid.uuid4()) diff --git a/src/chatgpt_subscription.py b/src/chatgpt_subscription.py index 39c430b56..d8e7f6201 100644 --- a/src/chatgpt_subscription.py +++ b/src/chatgpt_subscription.py @@ -258,10 +258,6 @@ def resolve_runtime_credentials( force_refresh: bool = False, allow_live_probes: bool = True, ) -> Dict[str, Any]: - if not allow_live_probes: - raise ChatGPTSubscriptionReauthRequired( - "ChatGPT Subscription credentials are unavailable when live probes are disabled." - ) ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles() db = SessionLocal() try: @@ -276,6 +272,21 @@ def resolve_runtime_credentials( raise ChatGPTSubscriptionAuthNotFound("ChatGPT Subscription credentials were not found for this user.") access_token = row.access_token or "" + if not allow_live_probes: + # Bearer chat may use an owner-scoped access token already held in + # the encrypted provider-auth row, but it must not refresh OAuth or + # probe the provider. Reject a missing/near-expiry cache entry so a + # request cannot reach the provider without valid Authorization. + if not access_token or access_token_is_expiring(access_token): + raise ChatGPTSubscriptionReauthRequired( + "ChatGPT Subscription credentials are unavailable without a live refresh." + ) + return { + "provider": CHATGPT_SUBSCRIPTION_PROVIDER, + "base_url": (row.base_url or DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL).rstrip("/"), + "api_key": access_token, + "auth_mode": row.auth_mode or "chatgpt", + } if force_refresh or access_token_is_expiring(access_token): with _refresh_lock_for(auth_id): db.refresh(row) diff --git a/src/endpoint_resolver.py b/src/endpoint_resolver.py index b4aa2ddd6..6f1e8acba 100644 --- a/src/endpoint_resolver.py +++ b/src/endpoint_resolver.py @@ -158,10 +158,13 @@ def resolve_endpoint_runtime( base = normalize_base(getattr(ep, "base_url", "") or "") api_key = getattr(ep, "api_key", None) auth_id = getattr(ep, "provider_auth_id", None) - if auth_id and allow_live_probes: + if auth_id: from src.chatgpt_subscription import resolve_runtime_credentials - creds = resolve_runtime_credentials(auth_id, owner=owner) + credential_kwargs = {} + if not allow_live_probes: + credential_kwargs["allow_live_probes"] = False + creds = resolve_runtime_credentials(auth_id, owner=owner, **credential_kwargs) base = normalize_base(creds.get("base_url") or base) api_key = creds.get("api_key") return base, api_key diff --git a/tests/test_api_chat_security.py b/tests/test_api_chat_security.py index 741c6f7ca..7f788bf9f 100644 --- a/tests/test_api_chat_security.py +++ b/tests/test_api_chat_security.py @@ -59,6 +59,9 @@ def _load_webhook_routes_for_test(monkeypatch): core_pkg.__path__ = [] core_db = types.ModuleType("core.database") core_db.SessionLocal = object + # The production sync-chat path reuses the central bearer session + # validator, which imports the durable Session model at invocation time. + core_db.Session = object core_db.Webhook = object core_db.ModelEndpoint = object core_middleware = types.ModuleType("core.middleware") @@ -242,10 +245,12 @@ def _install_sync_chat_stubs(monkeypatch): endpoint_resolver.normalize_base = lambda url: (url or "").strip().rstrip("/") endpoint_resolver.build_chat_url = lambda base_url: f"{base_url}/chat/completions" endpoint_resolver.build_models_url = lambda base_url: f"{base_url}/models" + endpoint_resolver.resolve_endpoint = lambda *args, **kwargs: None endpoint_resolver.build_headers = lambda api_key, base_url: {"Authorization": f"Bearer {api_key}"} llm_core = types.ModuleType("src.llm_core") llm_core.llm_call_async = _llm_call_async + llm_core.normalize_model_id = lambda *args, **kwargs: None core_models.ChatMessage = _ChatMessage monkeypatch.setitem(sys.modules, "python_multipart", python_multipart) diff --git a/tests/test_api_token_cycle6_repair.py b/tests/test_api_token_cycle6_repair.py index 861718372..906a2a5a3 100644 --- a/tests/test_api_token_cycle6_repair.py +++ b/tests/test_api_token_cycle6_repair.py @@ -346,7 +346,7 @@ def test_bearer_session_model_is_checked_against_endpoint_inventory(monkeypatch) @pytest.mark.asyncio -async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution(monkeypatch): +async def test_sync_chat_uses_cached_provider_auth_without_live_refresh(monkeypatch): from routes import webhook_routes from src import chatgpt_subscription, llm_core @@ -367,9 +367,10 @@ async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution monkeypatch.setattr( chatgpt_subscription, "resolve_runtime_credentials", - lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or pytest.fail( - "bearer sync resolved provider credentials" - ), + lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or { + "base_url": endpoint.base_url, + "api_key": "cached-access-token", + }, ) llm_calls = [] @@ -410,24 +411,58 @@ async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution result = await route(request=_Request(), body=body) assert result["model"] == "allowed-model" - assert runtime_calls == [] + assert runtime_calls == [ + (("provider-auth",), {"owner": "alice", "allow_live_probes": False}) + ] + assert llm_calls[0]["headers"]["Authorization"] == "Bearer cached-access-token" assert llm_calls[0]["allow_live_probes"] is False -def test_provider_runtime_guard_is_no_live_without_opening_credentials_db(monkeypatch): +def test_provider_runtime_guard_uses_cached_credentials_without_refresh(monkeypatch): from src import chatgpt_subscription + row = SimpleNamespace( + access_token="cached-access-token", + base_url="https://chatgpt.com/backend-api/codex", + auth_mode="chatgpt", + ) + + class _Query: + def filter(self, *args, **kwargs): + return self + + def first(self): + return row + + class _Db: + def query(self, _model): + return _Query() + + def refresh(self, _row): + raise AssertionError("cache-only provider resolution refreshed credentials") + + def close(self): + return None + monkeypatch.setattr( chatgpt_subscription, "_database_handles", - lambda: pytest.fail("no-live provider guard opened the credentials database"), + lambda: (cdb.ProviderAuthSession, lambda: _Db(), lambda: None), ) - with pytest.raises(chatgpt_subscription.ChatGPTSubscriptionReauthRequired): - chatgpt_subscription.resolve_runtime_credentials( - "provider-auth", - owner="alice", - allow_live_probes=False, - ) + monkeypatch.setattr( + chatgpt_subscription, "access_token_is_expiring", lambda token: False + ) + monkeypatch.setattr( + chatgpt_subscription, + "refresh_oauth_tokens", + lambda *args, **kwargs: pytest.fail("cache-only provider resolution refreshed credentials"), + ) + + result = chatgpt_subscription.resolve_runtime_credentials( + "provider-auth", owner="alice", allow_live_probes=False + ) + assert result["api_key"] == "cached-access-token" + assert result["base_url"] == "https://chatgpt.com/backend-api/codex" def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolution(monkeypatch): @@ -446,10 +481,14 @@ def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolutio hidden_models=None, ) monkeypatch.setattr(endpoint_resolver, "SessionLocal", lambda: _EndpointDb(endpoint)) + runtime_calls = [] monkeypatch.setattr( chatgpt_subscription, "resolve_runtime_credentials", - lambda *args, **kwargs: pytest.fail("foreground descriptor resolved provider credentials"), + lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or { + "base_url": endpoint.base_url, + "api_key": "cached-access-token", + }, ) descriptors = foreground_model_routing.build_foreground_route_descriptors( @@ -461,6 +500,9 @@ def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolutio allow_live_probes=False, ) assert descriptors[0]["endpoint_label"] in {"Subscription", "Selected route"} + assert runtime_calls == [ + (("provider-auth",), {"owner": "alice", "allow_live_probes": False}) + ] @pytest.mark.asyncio diff --git a/tests/test_api_token_repair_cycle8.py b/tests/test_api_token_repair_cycle8.py new file mode 100644 index 000000000..9801fab5c --- /dev/null +++ b/tests/test_api_token_repair_cycle8.py @@ -0,0 +1,94 @@ +"""Cycle-8 regressions for bearer provider-auth session repair.""" + +import json +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import core.database as cdb + + +_CODEX_BASE = "https://chatgpt.com/backend-api/codex" + + +def _provider_db(monkeypatch): + from routes import chat_helpers + from src import chatgpt_subscription + + engine = create_engine("sqlite:///:memory:") + cdb.Base.metadata.create_all(bind=engine) + test_session_local = sessionmaker(bind=engine, autoflush=False) + monkeypatch.setattr(chat_helpers, "SessionLocal", test_session_local) + monkeypatch.setattr( + chatgpt_subscription, + "_database_handles", + lambda: (cdb.ProviderAuthSession, test_session_local, cdb.utcnow_naive), + ) + + db = test_session_local() + db.add(cdb.ProviderAuthSession( + id="auth-1", + provider="chatgpt-subscription", + owner="alice", + base_url=_CODEX_BASE, + access_token="cached-access-token", + refresh_token="refresh-token", + auth_mode="chatgpt", + )) + db.add(cdb.ModelEndpoint( + id="endpoint-1", + name="ChatGPT Subscription", + base_url=_CODEX_BASE, + api_key=None, + provider_auth_id="auth-1", + owner="alice", + is_enabled=True, + endpoint_kind="api", + cached_models=json.dumps(["gpt-5.5"]), + pinned_models=json.dumps(["gpt-5.5"]), + )) + db.commit() + db.close() + return test_session_local + + +def _registered_session(): + return SimpleNamespace( + endpoint_url=f"{_CODEX_BASE}/responses", + model="gpt-5.5", + model_endpoint_id="endpoint-1", + endpoint_provenance="registered", + headers={"Authorization": "Bearer stale-token"}, + ) + + +def test_bearer_validator_uses_owner_cached_provider_auth_without_refresh(monkeypatch): + from routes.chat_helpers import _validate_bearer_session_model + from src import chatgpt_subscription + + _provider_db(monkeypatch) + monkeypatch.setattr(chatgpt_subscription, "access_token_is_expiring", lambda token: False) + monkeypatch.setattr( + chatgpt_subscription, + "refresh_oauth_tokens", + lambda *args, **kwargs: pytest.fail("bearer validation refreshed provider credentials"), + ) + + session = _registered_session() + assert _validate_bearer_session_model(session, owner="alice") == "gpt-5.5" + assert session.headers["Authorization"] == "Bearer cached-access-token" + + +def test_bearer_validator_rejects_provider_auth_when_cache_is_unusable(monkeypatch): + from routes.chat_helpers import _validate_bearer_session_model + from src import chatgpt_subscription + + _provider_db(monkeypatch) + monkeypatch.setattr(chatgpt_subscription, "access_token_is_expiring", lambda token: True) + + with pytest.raises(HTTPException) as exc: + _validate_bearer_session_model(_registered_session(), owner="alice") + assert exc.value.status_code == 401 diff --git a/tests/test_api_token_security_cycle3.py b/tests/test_api_token_security_cycle3.py index 031982592..34639c21e 100644 --- a/tests/test_api_token_security_cycle3.py +++ b/tests/test_api_token_security_cycle3.py @@ -162,8 +162,10 @@ def test_bearer_session_lifecycle_routes_do_not_emit_webhook_or_event(monkeypatc assert result.model == "stored-model" create_openai = _latest_endpoint(router, "/api/session/openai", "POST") - openai_result = create_openai(request, name="OpenAI", model="gpt-4o", rag="false") - assert openai_result["model"] == "gpt-4o" + with pytest.raises(HTTPException) as exc: + create_openai(request, name="OpenAI", model="gpt-4o", rag="false") + assert exc.value.status_code == 403 + assert len(manager.sessions) == 1 webhook_manager.fire_and_forget.assert_not_called() assert events == []