From f807e55d62825c668c8d543c1191ae59e9a6a80c Mon Sep 17 00:00:00 2001 From: crowscc Date: Mon, 3 Aug 2026 22:34:47 +0800 Subject: [PATCH 1/9] feat(proxy): support OAuth callers for Codex Live Voice --- app/core/auth/__init__.py | 22 + app/core/auth/codex_oauth_identity.py | 337 ++++++++++++ app/core/auth/dependencies.py | 94 +--- ...20260803_000000_add_oauth_live_policies.py | 71 +++ app/db/models.py | 59 ++ app/dependencies.py | 20 + app/main.py | 2 + app/modules/accounts/repository.py | 11 + app/modules/oauth_live/__init__.py | 1 + app/modules/oauth_live/api.py | 49 ++ app/modules/oauth_live/repository.py | 79 +++ app/modules/oauth_live/schemas.py | 17 + app/modules/oauth_live/service.py | 58 ++ app/modules/proxy/_service/codex_control.py | 16 +- app/modules/proxy/_service/realtime_live.py | 57 +- app/modules/proxy/api.py | 46 +- app/modules/proxy/realtime_auth.py | 105 ++++ app/modules/proxy/service.py | 10 +- docs/live-voice.md | 80 ++- frontend/screenshots/capture.spec.ts | 7 + .../components/account-multi-select.test.tsx | 45 ++ .../components/account-multi-select.tsx | 47 +- .../components/oauth-live-settings.test.tsx | 79 +++ .../components/oauth-live-settings.tsx | 127 +++++ .../components/settings-page.test.tsx | 21 +- .../settings/components/settings-page.tsx | 3 + .../settings/hooks/use-oauth-live-policy.ts | 33 ++ .../src/features/settings/oauth-live-api.ts | 17 + frontend/src/features/settings/schemas.ts | 15 + frontend/src/i18n/locales/en.json | 13 + frontend/src/i18n/locales/ko.json | 13 + frontend/src/i18n/locales/zh-CN.json | 13 + .../src/test/mocks/handler-coverage.test.ts | 2 + frontend/src/test/mocks/handlers.ts | 23 + .../add-oauth-live-voice-auth/.openspec.yaml | 2 + .../add-oauth-live-voice-auth/context.md | 21 + .../add-oauth-live-voice-auth/design.md | 49 ++ .../add-oauth-live-voice-auth/proposal.md | 25 + .../specs/account-identity/spec.md | 36 ++ .../specs/database-migrations/spec.md | 17 + .../specs/frontend-architecture/spec.md | 24 + .../specs/realtime-api-compat/spec.md | 154 ++++++ .../add-oauth-live-voice-auth/tasks.md | 18 + openspec/specs/account-identity/spec.md | 34 ++ openspec/specs/database-migrations/spec.md | 15 + openspec/specs/frontend-architecture/spec.md | 23 + openspec/specs/realtime-api-compat/context.md | 67 ++- openspec/specs/realtime-api-compat/spec.md | 45 +- tests/integration/test_accounts_repository.py | 35 ++ tests/integration/test_auth_middleware.py | 4 +- tests/integration/test_codex_usage_api.py | 15 +- tests/integration/test_migrations.py | 48 ++ tests/integration/test_oauth_live_policy.py | 157 ++++++ tests/integration/test_proxy_realtime_live.py | 194 ++++++- .../integration/test_proxy_sticky_sessions.py | 23 + .../test_auth_dependencies_upstream_proxy.py | 292 ++-------- tests/unit/test_codex_oauth_identity.py | 510 ++++++++++++++++++ tests/unit/test_db_migrate.py | 4 +- tests/unit/test_proxy_utils.py | 76 +++ tests/unit/test_realtime_auth.py | 112 ++++ tests/unit/test_realtime_live.py | 69 ++- 61 files changed, 3208 insertions(+), 453 deletions(-) create mode 100644 app/core/auth/codex_oauth_identity.py create mode 100644 app/db/alembic/versions/20260803_000000_add_oauth_live_policies.py create mode 100644 app/modules/oauth_live/__init__.py create mode 100644 app/modules/oauth_live/api.py create mode 100644 app/modules/oauth_live/repository.py create mode 100644 app/modules/oauth_live/schemas.py create mode 100644 app/modules/oauth_live/service.py create mode 100644 app/modules/proxy/realtime_auth.py create mode 100644 frontend/src/features/settings/components/oauth-live-settings.test.tsx create mode 100644 frontend/src/features/settings/components/oauth-live-settings.tsx create mode 100644 frontend/src/features/settings/hooks/use-oauth-live-policy.ts create mode 100644 frontend/src/features/settings/oauth-live-api.ts create mode 100644 openspec/changes/add-oauth-live-voice-auth/.openspec.yaml create mode 100644 openspec/changes/add-oauth-live-voice-auth/context.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/design.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/proposal.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/specs/database-migrations/spec.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md create mode 100644 openspec/changes/add-oauth-live-voice-auth/tasks.md create mode 100644 tests/integration/test_oauth_live_policy.py create mode 100644 tests/unit/test_codex_oauth_identity.py create mode 100644 tests/unit/test_realtime_auth.py diff --git a/app/core/auth/__init__.py b/app/core/auth/__init__.py index c90a1f89cd..7665254e6c 100644 --- a/app/core/auth/__init__.py +++ b/app/core/auth/__init__.py @@ -149,6 +149,28 @@ def resolve_seat_identity(claims: "IdTokenClaims", auth_claims: "OpenAIAuthClaim return clean_account_identity_part(resolved_auth.chatgpt_user_id or claims.chatgpt_user_id or claims.sub) +def resolve_seat_identity_aliases( + claims: "IdTokenClaims", + auth_claims: "OpenAIAuthClaims | None" = None, +) -> frozenset[str]: + """Return every stable per-seat principal alias carried by one JWT. + + Access and ID tokens can describe the same seat with different claim names: + ``chatgpt_user_id`` commonly uses a ``user-...`` value while ``sub`` can use + an Auth0 or social-login principal. Keeping all verified aliases lets callers + match an access token to the imported account's stored ID token without + assuming those two identifiers are byte-identical. + """ + + resolved_auth = auth_claims if auth_claims is not None else (claims.auth or OpenAIAuthClaims()) + aliases = ( + clean_account_identity_part(resolved_auth.chatgpt_user_id), + clean_account_identity_part(claims.chatgpt_user_id), + clean_account_identity_part(claims.sub), + ) + return frozenset(alias for alias in aliases if alias is not None) + + def parse_auth_json(raw: bytes) -> AuthFile: data = json.loads(raw) model = AuthFile.model_validate(data) diff --git a/app/core/auth/codex_oauth_identity.py b/app/core/auth/codex_oauth_identity.py new file mode 100644 index 0000000000..c6d99403a9 --- /dev/null +++ b/app/core/auth/codex_oauth_identity.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import asyncio +import hashlib +import time +from collections import OrderedDict +from dataclasses import dataclass + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import ( + clean_account_identity_part, + extract_id_token_claims, + resolve_seat_identity, + resolve_seat_identity_aliases, + token_expiry_epoch_ms, +) +from app.core.clients.usage import UsageFetchError, fetch_usage +from app.core.crypto import TokenEncryptor +from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError +from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route +from app.core.usage.models import UsagePayload +from app.db.models import Account +from app.db.session import get_background_session +from app.modules.accounts.repository import AccountsRepository + +_POSITIVE_CACHE_TTL_SECONDS = 60.0 +_DENIAL_CACHE_TTL_SECONDS = 5.0 +_CACHE_MAX_ENTRIES = 256 + + +@dataclass(frozen=True, slots=True) +class VerifiedCodexOAuthIdentity: + principal_id: str + caller_account_id: str | None + chatgpt_account_id: str + usage_payload: UsagePayload + route: ResolvedUpstreamRoute | None + + +@dataclass(frozen=True, slots=True) +class _CachedDenial: + message: str + + +@dataclass(frozen=True, slots=True) +class _CacheEntry: + value: VerifiedCodexOAuthIdentity | _CachedDenial + expires_at: float + + +_identity_cache: OrderedDict[str, _CacheEntry] = OrderedDict() +_identity_inflight: dict[str, asyncio.Task[VerifiedCodexOAuthIdentity]] = {} +_identity_lock = asyncio.Lock() + + +def clear_codex_oauth_identity_cache() -> None: + """Clear process-local identity state (used by tests and lifecycle resets).""" + + _identity_cache.clear() + _identity_inflight.clear() + + +async def resolve_verified_codex_oauth_identity( + authorization: str | None, + chatgpt_account_id: str | None, +) -> VerifiedCodexOAuthIdentity: + token = _extract_bearer_token(authorization) + if token is None: + raise ProxyAuthError("Missing ChatGPT token in Authorization header") + + normalized_account_id = clean_account_identity_part(chatgpt_account_id) + if normalized_account_id is None: + raise ProxyAuthError("Missing chatgpt-account-id header") + + cache_key = _credential_digest(token, normalized_account_id) + async with _identity_lock: + cached = _get_cached_locked(cache_key) + if cached is not None: + if isinstance(cached, _CachedDenial): + raise ProxyAuthError(cached.message) + return cached + + task = _identity_inflight.get(cache_key) + if task is None: + task = asyncio.create_task( + _validate_and_cache_identity( + cache_key=cache_key, + access_token=token, + chatgpt_account_id=normalized_account_id, + ) + ) + _identity_inflight[cache_key] = task + task.add_done_callback(lambda completed, key=cache_key: _remove_inflight(key, completed)) + + return await asyncio.shield(task) + + +async def _validate_and_cache_identity( + *, + cache_key: str, + access_token: str, + chatgpt_account_id: str, +) -> VerifiedCodexOAuthIdentity: + try: + identity = await _validate_identity_uncached( + access_token=access_token, + chatgpt_account_id=chatgpt_account_id, + ) + except ProxyAuthError as exc: + async with _identity_lock: + _set_cached_locked( + cache_key, + _CachedDenial(exc.message), + ttl_seconds=_DENIAL_CACHE_TTL_SECONDS, + ) + raise + + ttl_seconds = _positive_cache_ttl(access_token) + if ttl_seconds > 0: + async with _identity_lock: + _set_cached_locked(cache_key, identity, ttl_seconds=ttl_seconds) + return identity + + +async def _validate_identity_uncached( + *, + access_token: str, + chatgpt_account_id: str, +) -> VerifiedCodexOAuthIdentity: + stable_principal = resolve_seat_identity(extract_id_token_claims(access_token)) + validation_account_id: str | None = None + validation_route: ResolvedUpstreamRoute | None = None + async with get_background_session() as session: + candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(chatgpt_account_id) + validation_candidates = _prefer_token_seat_alias(candidates, access_token, TokenEncryptor()) + if validation_candidates: + validation_account_id = validation_candidates[0].id + validation_route = await _resolve_route(session, validation_account_id) + elif stable_principal is None: + raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") + else: + validation_route = await _resolve_route(session, None) + + try: + usage_payload = await fetch_usage( + access_token=access_token, + account_id=chatgpt_account_id, + route=validation_route, + allow_direct_egress=validation_route is None, + ) + except UsageFetchError as exc: + if exc.status_code == 429: + raise ProxyRateLimitError("ChatGPT credential validation rate limited") from exc + if exc.status_code in (401, 403): + raise ProxyAuthError("Invalid ChatGPT token or chatgpt-account-id") from exc + raise ProxyUpstreamError("Unable to validate ChatGPT credentials at this time") from exc + + async with get_background_session() as session: + candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(chatgpt_account_id) + caller = _resolve_unique_caller( + access_token=access_token, + usage_payload=usage_payload, + candidates=candidates, + encryptor=TokenEncryptor(), + ) + if caller is None and stable_principal is None: + raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") + caller_account_id = caller.id if caller is not None else None + if caller_account_id is None: + principal_id = f"principal:{stable_principal}" + route = validation_route + else: + # Imported callers use their Account id as stable affinity material. + principal_id = caller_account_id + route = ( + validation_route + if caller_account_id == validation_account_id + else await _resolve_route(session, caller_account_id) + ) + + return VerifiedCodexOAuthIdentity( + principal_id=principal_id, + caller_account_id=caller_account_id, + chatgpt_account_id=chatgpt_account_id, + usage_payload=usage_payload, + route=route, + ) + + +async def _resolve_route(session: AsyncSession, account_id: str | None) -> ResolvedUpstreamRoute | None: + try: + return await resolve_upstream_route( + session, + account_id=account_id, + operation="usage_identity", + scope="account", + encryptor=TokenEncryptor(), + ) + except UpstreamProxyRouteError as exc: + raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc + + +def _resolve_unique_caller( + *, + access_token: str, + usage_payload: UsagePayload, + candidates: list[Account], + encryptor: TokenEncryptor, +) -> Account | None: + contextual_candidates = _prefer_exact_verified_workspace(candidates, usage_payload) + contextual_candidates = _prefer_token_seat_alias(contextual_candidates, access_token, encryptor) + return contextual_candidates[0] if len(contextual_candidates) == 1 else None + + +def _prefer_token_seat_alias( + candidates: list[Account], + access_token: str, + encryptor: TokenEncryptor, +) -> list[Account]: + token_aliases = resolve_seat_identity_aliases(extract_id_token_claims(access_token)) + if not token_aliases: + return candidates + return [ + account for account in candidates if token_aliases.intersection(_account_identity_aliases(account, encryptor)) + ] + + +def _prefer_exact_verified_workspace(candidates: list[Account], usage_payload: UsagePayload) -> list[Account]: + verified_workspace_id = clean_account_identity_part(usage_payload.workspace_id) + if verified_workspace_id is not None: + exact_workspace = [ + account + for account in candidates + if clean_account_identity_part(account.workspace_id) == verified_workspace_id + ] + if exact_workspace: + return exact_workspace + + verified_label = clean_account_identity_part(usage_payload.workspace_label) + if verified_workspace_id is None and verified_label is not None: + exact_label = [ + account + for account in candidates + if (clean_account_identity_part(account.workspace_label) or "").casefold() == verified_label.casefold() + ] + if exact_label: + return exact_label + + return [account for account in candidates if _matches_verified_workspace(account, usage_payload)] + + +def _matches_verified_workspace(account: Account, usage_payload: UsagePayload) -> bool: + verified_workspace_id = clean_account_identity_part(usage_payload.workspace_id) + account_workspace_id = clean_account_identity_part(account.workspace_id) + if ( + verified_workspace_id is not None + and account_workspace_id is not None + and verified_workspace_id != account_workspace_id + ): + return False + + verified_label = clean_account_identity_part(usage_payload.workspace_label) + account_label = clean_account_identity_part(account.workspace_label) + return not ( + verified_workspace_id is None + and verified_label is not None + and account_label is not None + and verified_label.casefold() != account_label.casefold() + ) + + +def _account_identity_aliases(account: Account, encryptor: TokenEncryptor) -> frozenset[str]: + aliases = set() + account_user_id = clean_account_identity_part(account.chatgpt_user_id) + if account_user_id is not None: + aliases.add(account_user_id) + try: + id_token = encryptor.decrypt(account.id_token_encrypted) + except Exception: + return frozenset(aliases) + aliases.update(resolve_seat_identity_aliases(extract_id_token_claims(id_token))) + return frozenset(aliases) + + +def _extract_bearer_token(authorization: str | None) -> str | None: + if authorization is None: + return None + value = authorization.strip() + prefix = "bearer " + if not value.lower().startswith(prefix): + return None + return clean_account_identity_part(value[len(prefix) :]) + + +def _credential_digest(access_token: str, chatgpt_account_id: str) -> str: + material = f"{access_token}\0{chatgpt_account_id}".encode() + return hashlib.sha256(material).hexdigest() + + +def _positive_cache_ttl(access_token: str) -> float: + expires_at_ms = token_expiry_epoch_ms(access_token) + if expires_at_ms is None: + return _POSITIVE_CACHE_TTL_SECONDS + remaining_seconds = expires_at_ms / 1000.0 - time.time() + return max(0.0, min(_POSITIVE_CACHE_TTL_SECONDS, remaining_seconds)) + + +def _get_cached_locked(cache_key: str) -> VerifiedCodexOAuthIdentity | _CachedDenial | None: + entry = _identity_cache.get(cache_key) + if entry is None: + return None + if entry.expires_at <= time.monotonic(): + _identity_cache.pop(cache_key, None) + return None + _identity_cache.move_to_end(cache_key) + return entry.value + + +def _set_cached_locked( + cache_key: str, + value: VerifiedCodexOAuthIdentity | _CachedDenial, + *, + ttl_seconds: float, +) -> None: + _identity_cache[cache_key] = _CacheEntry(value=value, expires_at=time.monotonic() + ttl_seconds) + _identity_cache.move_to_end(cache_key) + while len(_identity_cache) > _CACHE_MAX_ENTRIES: + _identity_cache.popitem(last=False) + + +def _remove_inflight( + cache_key: str, + completed: asyncio.Task[VerifiedCodexOAuthIdentity], +) -> None: + if _identity_inflight.get(cache_key) is completed: + _identity_inflight.pop(cache_key, None) diff --git a/app/core/auth/dependencies.py b/app/core/auth/dependencies.py index 11ced12726..69f6c7eee4 100644 --- a/app/core/auth/dependencies.py +++ b/app/core/auth/dependencies.py @@ -9,8 +9,8 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from starlette.requests import HTTPConnection -from app.core.auth import generate_unique_account_id from app.core.auth.api_key_cache import get_api_key_cache +from app.core.auth.codex_oauth_identity import resolve_verified_codex_oauth_identity from app.core.auth.dashboard_access import ( DashboardPermission, DashboardPrincipal, @@ -19,18 +19,13 @@ guest_principal, ) from app.core.auth.dashboard_mode import DashboardAuthMode, get_dashboard_request_auth -from app.core.clients.usage import UsageFetchError, fetch_usage from app.core.config.settings import get_settings from app.core.config.settings_cache import get_settings_cache -from app.core.crypto import TokenEncryptor -from app.core.exceptions import DashboardAuthError, DashboardPermissionError, ProxyAuthError, ProxyUpstreamError +from app.core.exceptions import DashboardAuthError, DashboardPermissionError, ProxyAuthError from app.core.request_locality import is_local_request from app.core.socket_peer import raw_socket_peer_host -from app.core.upstream_proxy import UpstreamProxyRouteError, resolve_upstream_route from app.core.utils.time import utcnow -from app.db.models import AccountStatus from app.db.session import get_background_session -from app.modules.accounts.repository import AccountsRepository from app.modules.api_keys.repository import ApiKeysRepository from app.modules.api_keys.service import ApiKeyData, ApiKeyInvalidError, ApiKeysService from app.modules.dashboard_auth.service import DASHBOARD_SESSION_COOKIE, get_dashboard_session_store @@ -38,13 +33,6 @@ logger = logging.getLogger(__name__) _bearer = HTTPBearer(description="API key (e.g. sk-clb-…)", auto_error=False) -_CODEX_USAGE_IDENTITY_INACTIVE_WORKSPACE_STATUSES = { - AccountStatus.PAUSED, - AccountStatus.REAUTH_REQUIRED, - AccountStatus.DEACTIVATED, -} - - # --- Error format markers --- @@ -294,75 +282,19 @@ async def validate_codex_usage_identity(request: Request) -> ApiKeyData | None: if not token: raise ProxyAuthError("Missing ChatGPT token in Authorization header") - raw_account_id = request.headers.get("chatgpt-account-id") - account_id = raw_account_id.strip() if raw_account_id else "" - if not account_id: - if token.startswith("sk-clb-"): - return await _validate_api_key_token(token) - raise ProxyAuthError("Missing chatgpt-account-id header") - - async with get_background_session() as session: - accounts_repo = AccountsRepository(session) - account = await accounts_repo.get_active_by_chatgpt_account_id(account_id) - if account is None: - raise ProxyAuthError("Unknown or inactive chatgpt-account-id") - local_account_id = account.id - local_account_email = account.email - try: - route = await resolve_upstream_route( - session, - account_id=local_account_id, - operation="usage_identity", - scope="account", - encryptor=TokenEncryptor(), - ) - except UpstreamProxyRouteError as exc: - raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc + if token.startswith("sk-clb-"): + return await _validate_api_key_token(token) - try: - usage_payload = await fetch_usage( - access_token=token, - account_id=account_id, - route=route, - allow_direct_egress=route is None, - ) - except UsageFetchError as exc: - if exc.status_code == 429: - from app.core.exceptions import ProxyRateLimitError - - raise ProxyRateLimitError(exc.message) from exc - if exc.status_code in (401, 403): - raise ProxyAuthError("Invalid ChatGPT token or chatgpt-account-id") from exc - raise ProxyUpstreamError("Unable to validate ChatGPT credentials at this time") from exc - if usage_payload is not None and (usage_payload.workspace_id or usage_payload.workspace_label): - expected_account_id = generate_unique_account_id( - account_id, - local_account_email, - usage_payload.workspace_id, - usage_payload.workspace_label, - ) - async with get_background_session() as session: - accounts_repo = AccountsRepository(session) - workspace_account = await accounts_repo.get_by_id(expected_account_id) - if workspace_account is not None and workspace_account.chatgpt_account_id == account_id: - if workspace_account.status in _CODEX_USAGE_IDENTITY_INACTIVE_WORKSPACE_STATUSES: - raise ProxyAuthError("Unknown or inactive chatgpt-account-id") - local_account_id = workspace_account.id - try: - route = await resolve_upstream_route( - session, - account_id=local_account_id, - operation="usage_identity", - scope="account", - encryptor=TokenEncryptor(), - ) - except UpstreamProxyRouteError as exc: - raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc + identity = await resolve_verified_codex_oauth_identity( + request.headers.get("Authorization"), + request.headers.get("chatgpt-account-id"), + ) request.state.codex_usage_identity_access_token = token - request.state.codex_usage_identity_chatgpt_account_id = account_id - request.state.codex_usage_identity_account_id = local_account_id - request.state.codex_usage_identity_route = route - request.state.codex_usage_identity_payload = usage_payload + request.state.codex_usage_identity_chatgpt_account_id = identity.chatgpt_account_id + request.state.codex_usage_identity_account_id = identity.caller_account_id + request.state.codex_usage_identity_principal_id = identity.principal_id + request.state.codex_usage_identity_route = identity.route + request.state.codex_usage_identity_payload = identity.usage_payload return None diff --git a/app/db/alembic/versions/20260803_000000_add_oauth_live_policies.py b/app/db/alembic/versions/20260803_000000_add_oauth_live_policies.py new file mode 100644 index 0000000000..efd1b02175 --- /dev/null +++ b/app/db/alembic/versions/20260803_000000_add_oauth_live_policies.py @@ -0,0 +1,71 @@ +"""Add the global OAuth Live policy. + +Revision ID: 20260803_000000_add_oauth_live_policies +Revises: 20260731_000000_add_capability_lineage_markers +Create Date: 2026-08-03 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "20260803_000000_add_oauth_live_policies" +down_revision = "20260731_000000_add_capability_lineage_markers" +branch_labels = None +depends_on = None + +_POLICY_TABLE = "oauth_live_global_policy" +_ASSIGNMENTS_TABLE = "oauth_live_global_policy_accounts" +_ALLOWED_ACCOUNT_INDEX = "ix_oauth_live_global_policy_accounts_allowed_account_id" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(_POLICY_TABLE): + op.create_table( + _POLICY_TABLE, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("is_active", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint("id = 1", name="ck_oauth_live_global_policy_singleton"), + sa.PrimaryKeyConstraint("id"), + ) + + inspector = sa.inspect(bind) + if not inspector.has_table(_ASSIGNMENTS_TABLE): + op.create_table( + _ASSIGNMENTS_TABLE, + sa.Column("policy_id", sa.Integer(), nullable=False), + sa.Column("allowed_account_id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["policy_id"], [f"{_POLICY_TABLE}.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["allowed_account_id"], ["accounts.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("policy_id", "allowed_account_id"), + ) + assignment_indexes = ( + {str(index["name"]) for index in sa.inspect(bind).get_indexes(_ASSIGNMENTS_TABLE)} + if sa.inspect(bind).has_table(_ASSIGNMENTS_TABLE) + else set() + ) + if _ALLOWED_ACCOUNT_INDEX not in assignment_indexes: + op.create_index( + _ALLOWED_ACCOUNT_INDEX, + _ASSIGNMENTS_TABLE, + ["allowed_account_id"], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if inspector.has_table(_ASSIGNMENTS_TABLE): + indexes = {str(index["name"]) for index in inspector.get_indexes(_ASSIGNMENTS_TABLE)} + if _ALLOWED_ACCOUNT_INDEX in indexes: + op.drop_index(_ALLOWED_ACCOUNT_INDEX, table_name=_ASSIGNMENTS_TABLE) + op.drop_table(_ASSIGNMENTS_TABLE) + if sa.inspect(bind).has_table(_POLICY_TABLE): + op.drop_table(_POLICY_TABLE) diff --git a/app/db/models.py b/app/db/models.py index 521a47d02b..1405f3daa6 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -7,6 +7,7 @@ from sqlalchemy import ( BigInteger, Boolean, + CheckConstraint, DateTime, Float, ForeignKey, @@ -1105,6 +1106,64 @@ class ApiKey(Base): ) +class OAuthLivePolicy(Base): + __tablename__ = "oauth_live_global_policy" + __table_args__ = (CheckConstraint("id = 1", name="ck_oauth_live_global_policy_singleton"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + allowed_accounts: Mapped[list["OAuthLivePolicyAccount"]] = relationship( + "OAuthLivePolicyAccount", + back_populates="policy", + cascade="all, delete-orphan", + passive_deletes=True, + lazy="selectin", + ) + + +class OAuthLivePolicyAccount(Base): + __tablename__ = "oauth_live_global_policy_accounts" + __table_args__ = ( + Index( + "ix_oauth_live_global_policy_accounts_allowed_account_id", + "allowed_account_id", + ), + ) + + policy_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("oauth_live_global_policy.id", ondelete="CASCADE"), + primary_key=True, + ) + allowed_account_id: Mapped[str] = mapped_column( + String, + ForeignKey("accounts.id", ondelete="CASCADE"), + primary_key=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + policy: Mapped["OAuthLivePolicy"] = relationship( + "OAuthLivePolicy", + back_populates="allowed_accounts", + ) + + class ApiKeyAccountAssignment(Base): __tablename__ = "api_key_accounts" diff --git a/app/dependencies.py b/app/dependencies.py index 8fd8131e07..bf28bb789d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -32,6 +32,8 @@ from app.modules.model_sources.repository import ModelSourcesRepository from app.modules.model_sources.service import ModelSourcesService from app.modules.oauth.service import OauthService +from app.modules.oauth_live.repository import OAuthLivePolicyRepository +from app.modules.oauth_live.service import OAuthLivePolicyService from app.modules.proxy.capability_lineage_repository import CapabilityLineageRepository from app.modules.proxy.repo_bundle import ProxyRepositories from app.modules.proxy.service import ProxyService @@ -74,6 +76,13 @@ class OauthContext: service: OauthService +@dataclass(slots=True) +class OAuthLivePolicyContext: + session: AsyncSession + repository: OAuthLivePolicyRepository + service: OAuthLivePolicyService + + @dataclass(slots=True) class DashboardAuthContext: session: AsyncSession @@ -232,6 +241,17 @@ def get_oauth_context( return OauthContext(service=OauthService(accounts_repository, repo_factory=_accounts_repo_context)) +def get_oauth_live_policy_context( + session: AsyncSession = Depends(get_session), +) -> OAuthLivePolicyContext: + repository = OAuthLivePolicyRepository(session) + return OAuthLivePolicyContext( + session=session, + repository=repository, + service=OAuthLivePolicyService(repository), + ) + + def get_dashboard_auth_context( session: AsyncSession = Depends(get_session), ) -> DashboardAuthContext: diff --git a/app/main.py b/app/main.py index 4b59dbe295..cec2cdfaec 100644 --- a/app/main.py +++ b/app/main.py @@ -75,6 +75,7 @@ from app.modules.health import api as health_api from app.modules.model_sources import api as model_sources_api from app.modules.oauth import api as oauth_api +from app.modules.oauth_live import api as oauth_live_api from app.modules.proxy import api as proxy_api from app.modules.proxy.cap_partitioning import refresh_cap_partition from app.modules.proxy.durable_bridge_coordinator import DurableBridgeSessionCoordinator @@ -693,6 +694,7 @@ def create_app() -> FastAPI: app.include_router(conversation_archive_api.router) app.include_router(runtime_api.router) app.include_router(oauth_api.router) + app.include_router(oauth_live_api.router) app.include_router(dashboard_auth_api.router) app.include_router(settings_api.router) app.include_router(firewall_api.router) diff --git a/app/modules/accounts/repository.py b/app/modules/accounts/repository.py index 1e8e5e1232..35b025aa0e 100644 --- a/app/modules/accounts/repository.py +++ b/app/modules/accounts/repository.py @@ -168,6 +168,17 @@ async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Acc ) return result.scalar_one_or_none() + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + result = await self._session.execute( + select(Account) + .where(Account.chatgpt_account_id == chatgpt_account_id) + .where( + Account.status.notin_((AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED, AccountStatus.PAUSED)) + ) + .order_by(Account.id) + ) + return list(result.scalars().all()) + async def upsert( self, account: Account, diff --git a/app/modules/oauth_live/__init__.py b/app/modules/oauth_live/__init__.py new file mode 100644 index 0000000000..ad4d44ea3b --- /dev/null +++ b/app/modules/oauth_live/__init__.py @@ -0,0 +1 @@ +"""OAuth-authenticated Live Voice caller policy.""" diff --git a/app/modules/oauth_live/api.py b/app/modules/oauth_live/api.py new file mode 100644 index 0000000000..bc26ffbf86 --- /dev/null +++ b/app/modules/oauth_live/api.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends, Request + +from app.core.audit.service import AuditService +from app.core.auth.dependencies import ( + require_dashboard_write_access, + set_dashboard_error_format, + validate_dashboard_session, +) +from app.core.exceptions import DashboardBadRequestError +from app.dependencies import OAuthLivePolicyContext, get_oauth_live_policy_context +from app.modules.oauth_live.schemas import OAuthLivePolicyResponse, OAuthLivePolicyUpdateRequest +from app.modules.oauth_live.service import OAuthLivePolicyValidationError + +router = APIRouter( + prefix="/api/oauth-live-policy", + tags=["dashboard"], + dependencies=[Depends(validate_dashboard_session), Depends(set_dashboard_error_format)], +) + + +@router.get("", response_model=OAuthLivePolicyResponse) +async def get_oauth_live_policy( + context: OAuthLivePolicyContext = Depends(get_oauth_live_policy_context), +) -> OAuthLivePolicyResponse: + return await context.service.get_policy() + + +@router.put("", response_model=OAuthLivePolicyResponse) +async def update_oauth_live_policy( + request: Request, + payload: OAuthLivePolicyUpdateRequest = Body(...), + _write_access=Depends(require_dashboard_write_access), + context: OAuthLivePolicyContext = Depends(get_oauth_live_policy_context), +) -> OAuthLivePolicyResponse: + try: + updated = await context.service.update_policy(payload) + except OAuthLivePolicyValidationError as exc: + raise DashboardBadRequestError(str(exc), code="invalid_oauth_live_policy") from exc + AuditService.log_async( + "oauth_live_policy_updated", + actor_ip=request.client.host if request.client else None, + details={ + "is_active": updated.is_active, + "allowed_account_count": len(updated.allowed_account_ids), + }, + ) + return updated diff --git a/app/modules/oauth_live/repository.py b/app/modules/oauth_live/repository.py new file mode 100644 index 0000000000..e3628329e7 --- /dev/null +++ b/app/modules/oauth_live/repository.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Account, AccountStatus, OAuthLivePolicy, OAuthLivePolicyAccount + +GLOBAL_POLICY_ID = 1 + + +class OAuthLivePolicyRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def existing_account_ids(self, account_ids: list[str]) -> frozenset[str]: + if not account_ids: + return frozenset() + rows = await self._session.scalars(select(Account.id).where(Account.id.in_(account_ids))) + return frozenset(rows.all()) + + async def get_policy(self) -> OAuthLivePolicy | None: + result = await self._session.execute( + select(OAuthLivePolicy) + .options(selectinload(OAuthLivePolicy.allowed_accounts)) + .where(OAuthLivePolicy.id == GLOBAL_POLICY_ID) + ) + return result.scalar_one_or_none() + + async def replace_policy( + self, + *, + is_active: bool, + allowed_account_ids: list[str], + ) -> OAuthLivePolicy: + policy = await self._session.get(OAuthLivePolicy, GLOBAL_POLICY_ID) + if policy is None: + policy = OAuthLivePolicy(id=GLOBAL_POLICY_ID, is_active=is_active) + self._session.add(policy) + await self._session.flush() + else: + policy.is_active = is_active + policy.updated_at = datetime.now(timezone.utc) + + await self._session.execute( + delete(OAuthLivePolicyAccount).where(OAuthLivePolicyAccount.policy_id == GLOBAL_POLICY_ID) + ) + self._session.add_all( + OAuthLivePolicyAccount( + policy_id=GLOBAL_POLICY_ID, + allowed_account_id=allowed_account_id, + ) + for allowed_account_id in allowed_account_ids + ) + await self._session.commit() + refreshed = await self.get_policy() + assert refreshed is not None + return refreshed + + async def get_active_allowed_account_ids(self) -> frozenset[str]: + rows = await self._session.scalars( + select(OAuthLivePolicyAccount.allowed_account_id) + .join( + OAuthLivePolicy, + OAuthLivePolicy.id == OAuthLivePolicyAccount.policy_id, + ) + .join(Account, Account.id == OAuthLivePolicyAccount.allowed_account_id) + .where( + OAuthLivePolicy.id == GLOBAL_POLICY_ID, + OAuthLivePolicy.is_active.is_(True), + Account.status == AccountStatus.ACTIVE, + ) + ) + return frozenset(rows.all()) + + async def rollback(self) -> None: + await self._session.rollback() diff --git a/app/modules/oauth_live/schemas.py b/app/modules/oauth_live/schemas.py new file mode 100644 index 0000000000..a86efa71de --- /dev/null +++ b/app/modules/oauth_live/schemas.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from datetime import datetime + +from app.modules.shared.schemas import DashboardModel + + +class OAuthLivePolicyUpdateRequest(DashboardModel): + is_active: bool + allowed_account_ids: list[str] + + +class OAuthLivePolicyResponse(DashboardModel): + is_active: bool + allowed_account_ids: list[str] + created_at: datetime | None + updated_at: datetime | None diff --git a/app/modules/oauth_live/service.py b/app/modules/oauth_live/service.py new file mode 100644 index 0000000000..8b634b407a --- /dev/null +++ b/app/modules/oauth_live/service.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from app.db.models import OAuthLivePolicy +from app.modules.oauth_live.repository import OAuthLivePolicyRepository +from app.modules.oauth_live.schemas import OAuthLivePolicyResponse, OAuthLivePolicyUpdateRequest + + +class OAuthLivePolicyValidationError(ValueError): + pass + + +class OAuthLivePolicyService: + def __init__(self, repository: OAuthLivePolicyRepository) -> None: + self._repository = repository + + async def get_policy(self) -> OAuthLivePolicyResponse: + row = await self._repository.get_policy() + if row is None: + return OAuthLivePolicyResponse( + is_active=False, + allowed_account_ids=[], + created_at=None, + updated_at=None, + ) + return _to_response(row) + + async def update_policy( + self, + payload: OAuthLivePolicyUpdateRequest, + ) -> OAuthLivePolicyResponse: + allowed_account_ids = sorted( + {account_id.strip() for account_id in payload.allowed_account_ids if account_id.strip()} + ) + if payload.is_active and not allowed_account_ids: + raise OAuthLivePolicyValidationError("An active OAuth Live policy requires at least one allowed account") + + existing_ids = await self._repository.existing_account_ids(allowed_account_ids) + if len(existing_ids) != len(allowed_account_ids): + raise OAuthLivePolicyValidationError("OAuth Live policy contains an unknown allowed account") + + try: + row = await self._repository.replace_policy( + is_active=payload.is_active, + allowed_account_ids=allowed_account_ids, + ) + except Exception: + await self._repository.rollback() + raise + return _to_response(row) + + +def _to_response(row: OAuthLivePolicy) -> OAuthLivePolicyResponse: + return OAuthLivePolicyResponse( + is_active=row.is_active, + allowed_account_ids=sorted(assignment.allowed_account_id for assignment in row.allowed_accounts), + created_at=row.created_at, + updated_at=row.updated_at, + ) diff --git a/app/modules/proxy/_service/codex_control.py b/app/modules/proxy/_service/codex_control.py index 1e768bf761..27d9c7e92e 100644 --- a/app/modules/proxy/_service/codex_control.py +++ b/app/modules/proxy/_service/codex_control.py @@ -52,6 +52,7 @@ async def _select_codex_control_account_without_budget( *, affinity: _AffinityPolicy, api_key: ApiKeyData | None, + allowed_account_ids: frozenset[str] | None = None, traffic_class: TrafficClass = TRAFFIC_CLASS_FOREGROUND, prefer_earlier_reset_window: ResetPreferenceWindow = "secondary", privacy_policy: CodexControlRequestPrivacyPolicy = CodexControlRequestPrivacyPolicy.STANDARD, @@ -204,16 +205,24 @@ async def _select_codex_control_account_without_budget( *, affinity: _AffinityPolicy, api_key: ApiKeyData | None, + allowed_account_ids: frozenset[str] | None = None, traffic_class: TrafficClass = TRAFFIC_CLASS_FOREGROUND, prefer_earlier_reset_window: ResetPreferenceWindow = "secondary", privacy_policy: CodexControlRequestPrivacyPolicy = CodexControlRequestPrivacyPolicy.STANDARD, ) -> Account | None: proxy = cast(_CodexControlServiceProtocol, self) - scoped_account_ids = ( + key_scoped_account_ids = ( set(api_key.assigned_account_ids) if api_key is not None and api_key.account_assignment_scope_enabled else None ) + scoped_account_ids = ( + key_scoped_account_ids + if allowed_account_ids is None + else set(allowed_account_ids) + if key_scoped_account_ids is None + else key_scoped_account_ids.intersection(allowed_account_ids) + ) settings = await _service_get_settings_cache().get() if _routing_strategy(settings) == "single_account": selected_account_id = (settings.single_account_id or "").strip() @@ -252,6 +261,7 @@ async def codex_control_request( headers: Mapping[str, str], codex_session_affinity: bool = True, api_key: ApiKeyData | None = None, + allowed_account_ids: frozenset[str] | None = None, success_gate: Callable[[str, CodexControlResponse], Awaitable[bool]] | None = None, privacy_policy: CodexControlRequestPrivacyPolicy = CodexControlRequestPrivacyPolicy.STANDARD, ) -> CodexControlResponse: @@ -317,6 +327,7 @@ async def _finalize_success( request_id=request_id, kind=request_kind, api_key=api_key, + allowed_account_ids=allowed_account_ids, affinity_policy=affinity, prefer_earlier_reset_accounts=settings.prefer_earlier_reset_accounts, prefer_earlier_reset_window=_prefer_earlier_reset_window(settings), @@ -329,6 +340,7 @@ async def _finalize_success( account = await proxy._select_codex_control_account_without_budget( affinity=affinity, api_key=api_key, + allowed_account_ids=allowed_account_ids, traffic_class=TRAFFIC_CLASS_OPPORTUNISTIC if api_key is not None and api_key.traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC else TRAFFIC_CLASS_FOREGROUND, @@ -392,6 +404,7 @@ async def _select_control_failover(excluded_account_ids: set[str]) -> AccountSel request_id=request_id, kind=request_kind, api_key=api_key, + allowed_account_ids=allowed_account_ids, sticky_key=affinity.selection_key, sticky_kind=affinity.kind, reallocate_sticky=affinity.reallocate_sticky, @@ -484,6 +497,7 @@ async def _select_control_failover(excluded_account_ids: set[str]) -> AccountSel request_id=request_id, kind=request_kind, api_key=api_key, + allowed_account_ids=allowed_account_ids, sticky_key=affinity.selection_key, sticky_kind=affinity.kind, reallocate_sticky=affinity.reallocate_sticky, diff --git a/app/modules/proxy/_service/realtime_live.py b/app/modules/proxy/_service/realtime_live.py index c09b7cca38..149bd8e1c4 100644 --- a/app/modules/proxy/_service/realtime_live.py +++ b/app/modules/proxy/_service/realtime_live.py @@ -31,6 +31,7 @@ from app.modules.proxy._service.support import _request_log_client_fields from app.modules.proxy.helpers import _header_account_id from app.modules.proxy.load_balancer import AccountLease, AccountSelection +from app.modules.proxy.realtime_auth import RealtimeCallerScope from app.modules.proxy.repo_bundle import ProxyRepoFactory from app.modules.proxy.sticky_repository import RESERVED_STICKY_SESSION_KEY_PREFIX @@ -92,7 +93,8 @@ async def _select_account_with_budget_compatible( *, request_id: str, kind: str, - api_key: ApiKeyData, + api_key: ApiKeyData | None, + allowed_account_ids: frozenset[str] | None, model: str | None, preferred_account_id: str, preferred_account_is_continuity_owner: bool, @@ -113,7 +115,7 @@ async def _write_request_log( self, *, account_id: str | None, - api_key: ApiKeyData, + api_key: ApiKeyData | None, request_id: str, model: str | None, latency_ms: int, @@ -155,14 +157,26 @@ def realtime_call_id_from_location(headers: Mapping[str, str]) -> str | None: return normalize_realtime_call_id(path_segments[4]) -def realtime_call_affinity_key(call_id: str, api_key: ApiKeyData) -> str: +def realtime_call_affinity_key(call_id: str, caller: RealtimeCallerScope | ApiKeyData) -> str: normalized = normalize_realtime_call_id(call_id) if normalized is None: raise ValueError("Invalid realtime call id") - digest = hashlib.sha256(f"{api_key.id}\0{normalized}".encode()).hexdigest() + scope_material = caller.affinity_scope_material if isinstance(caller, RealtimeCallerScope) else caller.id + digest = hashlib.sha256(f"{scope_material}\0{normalized}".encode()).hexdigest() return f"{_REALTIME_CALL_AFFINITY_PREFIX}{digest}" +def _caller_scope( + caller_scope: RealtimeCallerScope | None, + api_key: ApiKeyData | None, +) -> RealtimeCallerScope: + if caller_scope is not None: + return caller_scope + if api_key is not None: + return RealtimeCallerScope.for_api_key(api_key) + raise TypeError("Realtime caller scope is required") + + def _valid_close_code(value: int | None, *, default: int) -> int: if value is None: return default @@ -396,7 +410,8 @@ async def bind_realtime_call_owner( *, response_headers: Mapping[str, str], account_id: str, - api_key: ApiKeyData, + caller_scope: RealtimeCallerScope | None = None, + api_key: ApiKeyData | None = None, ) -> str | None: call_id = realtime_call_id_from_location(response_headers) if call_id is None: @@ -404,7 +419,8 @@ async def bind_realtime_call_owner( return None proxy = cast(_RealtimeLiveServiceProtocol, self) - affinity_key = realtime_call_affinity_key(call_id, api_key) + scope = _caller_scope(caller_scope, api_key) + affinity_key = realtime_call_affinity_key(call_id, scope) async with proxy._repo_factory() as repos: persisted_owner_id = await repos.sticky_sessions.get_account_id( affinity_key, @@ -427,10 +443,11 @@ async def _resolve_realtime_call_owner( self, call_id: str, *, - api_key: ApiKeyData, + caller_scope: RealtimeCallerScope | None = None, + api_key: ApiKeyData | None = None, ) -> str | None: proxy = cast(_RealtimeLiveServiceProtocol, self) - affinity_key = realtime_call_affinity_key(call_id, api_key) + affinity_key = realtime_call_affinity_key(call_id, _caller_scope(caller_scope, api_key)) async with proxy._repo_factory() as repos: return await repos.sticky_sessions.get_account_id( affinity_key, @@ -446,7 +463,8 @@ async def proxy_realtime_live_websocket( query_params: Mapping[str, str] | Sequence[tuple[str, str]] = (), *, protocol: RealtimeWebSocketProtocol, - api_key: ApiKeyData, + caller_scope: RealtimeCallerScope | None = None, + api_key: ApiKeyData | None = None, client_ip: str | None = None, ) -> None: normalized_call_id = normalize_realtime_call_id(call_id) @@ -457,10 +475,18 @@ async def proxy_realtime_live_websocket( ) proxy = cast(_RealtimeLiveServiceProtocol, self) - owner_account_id = await self._resolve_realtime_call_owner(normalized_call_id, api_key=api_key) - if owner_account_id is None or ( - api_key.account_assignment_scope_enabled and owner_account_id not in api_key.assigned_account_ids - ): + scope = _caller_scope(caller_scope, api_key) + if scope.api_key is not None: + owner_account_id = await self._resolve_realtime_call_owner( + normalized_call_id, + api_key=scope.api_key, + ) + else: + owner_account_id = await self._resolve_realtime_call_owner( + normalized_call_id, + caller_scope=scope, + ) + if owner_account_id is None or not scope.allows_account(owner_account_id): raise ProxyResponseError( 404, openai_error("realtime_call_not_found", "Realtime call binding not found or expired"), @@ -475,7 +501,8 @@ async def proxy_realtime_live_websocket( start + settings.proxy_request_budget_seconds, request_id=request_id, kind="realtime_live_websocket", - api_key=api_key, + api_key=scope.api_key, + allowed_account_ids=scope.allowed_account_ids, model=None, preferred_account_id=owner_account_id, preferred_account_is_continuity_owner=True, @@ -614,7 +641,7 @@ async def proxy_realtime_live_websocket( try: await proxy._write_request_log( account_id=None, - api_key=api_key, + api_key=scope.api_key, request_id=request_id, model=None, latency_ms=int((time.monotonic() - start) * 1000), diff --git a/app/modules/proxy/api.py b/app/modules/proxy/api.py index 296979a67d..eb71178946 100644 --- a/app/modules/proxy/api.py +++ b/app/modules/proxy/api.py @@ -36,7 +36,6 @@ validate_codex_usage_identity, validate_proxy_api_key, validate_proxy_api_key_authorization, - validate_required_proxy_api_key, validate_required_proxy_api_key_authorization, validate_usage_api_key, ) @@ -217,6 +216,7 @@ IMAGE_ROUTE_STREAM_STATE, record_images_route_observability, ) +from app.modules.proxy.realtime_auth import RealtimeCallerScope, resolve_realtime_caller_scope from app.modules.proxy.request_policy import ( apply_api_key_enforcement, apply_api_key_enforcement_to_chat_payload, @@ -777,7 +777,7 @@ async def finalize( @dataclass(slots=True) class _RealtimeCallCodexControlAdapter: context: ProxyContext - api_key: ApiKeyData + caller_scope: RealtimeCallerScope _binding_failure_message: str | None = "Realtime call owner could not be determined" @property @@ -800,7 +800,7 @@ async def _bind_successful_call_owner( bound_call_id = await self.context.service.bind_realtime_call_owner( response_headers=response.headers, account_id=account_id, - api_key=self.api_key, + caller_scope=self.caller_scope, ) except asyncio.CancelledError: current_task = asyncio.current_task() @@ -851,8 +851,10 @@ async def _codex_control_proxy( context: ProxyContext, api_key: ApiKeyData | None, *, + allowed_account_ids: frozenset[str] | None = None, adapter: _CodexControlAdapter = _PASSTHROUGH_CODEX_CONTROL_ADAPTER, ) -> Response: + account_scope_kwargs = {} if allowed_account_ids is None else {"allowed_account_ids": allowed_account_ids} try: response = await context.service.codex_control_request( path, @@ -864,6 +866,7 @@ async def _codex_control_proxy( api_key=api_key, privacy_policy=adapter.privacy_policy, success_gate=adapter.success_gate, + **account_scope_kwargs, ) except ProxyResponseError as exc: if adapter.privacy_policy is CodexControlRequestPrivacyPolicy.PRIVATE_REALTIME: @@ -930,14 +933,19 @@ async def codex_memories_trace_summarize( async def codex_realtime_calls( request: Request, context: ProxyContext = Depends(get_proxy_context), - api_key: ApiKeyData = Security(validate_required_proxy_api_key), ) -> Response: + caller_scope = await resolve_realtime_caller_scope( + request.headers.get("authorization"), + request.headers.get("chatgpt-account-id"), + api_key_validator=validate_required_proxy_api_key_authorization, + ) return await _codex_control_proxy( request, "realtime/calls", context, - api_key, - adapter=_RealtimeCallCodexControlAdapter(context, api_key), + caller_scope.api_key, + allowed_account_ids=caller_scope.allowed_account_ids, + adapter=_RealtimeCallCodexControlAdapter(context, caller_scope), ) @@ -1294,11 +1302,11 @@ async def _proxy_realtime_live_websocket_route( redacted_path: str, ) -> None: _redact_realtime_live_websocket_scope(websocket, path=redacted_path) - api_key, denial = await _validate_proxy_websocket_request(websocket, require_api_key=True) + caller_scope, denial = await _validate_realtime_caller_websocket_request(websocket) if denial is not None: await websocket.send_denial_response(denial) return - assert api_key is not None + assert caller_scope is not None try: if protocol is RealtimeWebSocketProtocol.LIVE_V3 and any(key == "call_id" for key, _value in query_params): raise ProxyResponseError( @@ -1314,7 +1322,7 @@ async def _proxy_realtime_live_websocket_route( dict(websocket.headers), query_params, protocol=protocol, - api_key=api_key, + caller_scope=caller_scope, client_ip=resolve_request_client_host(websocket), ) except ProxyResponseError as exc: @@ -6270,6 +6278,26 @@ async def _validate_proxy_websocket_request( return api_key, None +async def _validate_realtime_caller_websocket_request( + websocket: WebSocket, +) -> tuple[RealtimeCallerScope | None, JSONResponse | None]: + denial = await _websocket_firewall_denial_response(websocket) + if denial is not None: + return None, denial + try: + caller_scope = await resolve_realtime_caller_scope( + websocket.headers.get("authorization"), + websocket.headers.get("chatgpt-account-id"), + api_key_validator=validate_required_proxy_api_key_authorization, + ) + except (ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError) as exc: + return None, JSONResponse( + status_code=exc.status_code, + content=openai_error(exc.code, exc.message, error_type=exc.error_type), + ) + return caller_scope, None + + def _redact_realtime_live_websocket_scope(websocket: WebSocket, *, path: str) -> None: """Remove opaque live identifiers before Uvicorn emits handshake logs.""" diff --git a/app/modules/proxy/realtime_auth.py b/app/modules/proxy/realtime_auth.py new file mode 100644 index 0000000000..0ddcf545a5 --- /dev/null +++ b/app/modules/proxy/realtime_auth.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Collection +from dataclasses import dataclass +from typing import Literal + +from app.core.auth.dependencies import validate_required_proxy_api_key_authorization +from app.core.exceptions import ProxyAuthError +from app.db.session import get_background_session +from app.modules.api_keys.service import ApiKeyData + +RealtimeCallerKind = Literal["api_key", "oauth"] +ApiKeyValidator = Callable[[str | None], Awaitable[ApiKeyData]] + + +class OAuthLiveNotEnabledError(ProxyAuthError): + status_code = 403 + code = "oauth_live_not_enabled" + error_type = "permission_error" + message = "OAuth Live Voice is not enabled" + + +@dataclass(frozen=True, slots=True) +class RealtimeCallerScope: + kind: RealtimeCallerKind + affinity_scope_material: str + api_key: ApiKeyData | None + oauth_principal_id: str | None + allowed_account_ids: frozenset[str] | None + + @classmethod + def for_api_key(cls, api_key: ApiKeyData) -> RealtimeCallerScope: + return cls( + kind="api_key", + affinity_scope_material=api_key.id, + api_key=api_key, + oauth_principal_id=None, + allowed_account_ids=None, + ) + + @classmethod + def for_oauth( + cls, + *, + principal_id: str, + allowed_account_ids: Collection[str], + ) -> RealtimeCallerScope: + allowed = frozenset(allowed_account_ids) + if not allowed: + raise OAuthLiveNotEnabledError() + return cls( + kind="oauth", + affinity_scope_material=f"oauth:{principal_id}", + api_key=None, + oauth_principal_id=principal_id, + allowed_account_ids=allowed, + ) + + def allows_account(self, account_id: str) -> bool: + if self.allowed_account_ids is not None: + return account_id in self.allowed_account_ids + api_key = self.api_key + return bool( + api_key is not None + and (not api_key.account_assignment_scope_enabled or account_id in api_key.assigned_account_ids) + ) + + +def _extract_bearer_token(authorization: str | None) -> str | None: + if authorization is None: + return None + scheme, separator, value = authorization.strip().partition(" ") + if not separator or scheme.lower() != "bearer": + return None + token = value.strip() + return token or None + + +async def _active_oauth_live_allowed_account_ids() -> frozenset[str]: + # Imported lazily so the proxy contract stays acyclic while the policy + # module owns its ORM and migration lifecycle. + from app.modules.oauth_live.repository import OAuthLivePolicyRepository + + async with get_background_session() as session: + return await OAuthLivePolicyRepository(session).get_active_allowed_account_ids() + + +async def resolve_realtime_caller_scope( + authorization: str | None, + chatgpt_account_id: str | None, + *, + api_key_validator: ApiKeyValidator = validate_required_proxy_api_key_authorization, +) -> RealtimeCallerScope: + token = _extract_bearer_token(authorization) + if token is not None and token.startswith("sk-clb-"): + return RealtimeCallerScope.for_api_key(await api_key_validator(authorization)) + + from app.core.auth.codex_oauth_identity import resolve_verified_codex_oauth_identity + + identity = await resolve_verified_codex_oauth_identity(authorization, chatgpt_account_id) + allowed_account_ids = await _active_oauth_live_allowed_account_ids() + return RealtimeCallerScope.for_oauth( + principal_id=identity.principal_id, + allowed_account_ids=allowed_account_ids, + ) diff --git a/app/modules/proxy/service.py b/app/modules/proxy/service.py index b077a794fd..a939e8a251 100644 --- a/app/modules/proxy/service.py +++ b/app/modules/proxy/service.py @@ -1689,6 +1689,7 @@ async def _select_account_with_budget( kind: str, request_stage: str = "first_turn", api_key: ApiKeyData | None = None, + allowed_account_ids: Collection[str] | None = None, sticky_key: str | None = None, sticky_kind: StickySessionKind | None = None, reallocate_sticky: bool = False, @@ -1719,11 +1720,18 @@ async def _select_account_with_budget( "%s request budget exhausted before account selection request_id=%s", kind.title(), request_id ) _raise_proxy_budget_exhausted() - scoped_account_ids = ( + key_scoped_account_ids = ( set(api_key.assigned_account_ids) if api_key is not None and api_key.account_assignment_scope_enabled else None ) + scoped_account_ids = ( + key_scoped_account_ids + if allowed_account_ids is None + else set(allowed_account_ids) + if key_scoped_account_ids is None + else key_scoped_account_ids.intersection(allowed_account_ids) + ) effective_traffic_class = ( TRAFFIC_CLASS_OPPORTUNISTIC if api_key is not None and api_key.traffic_class == TRAFFIC_CLASS_OPPORTUNISTIC diff --git a/docs/live-voice.md b/docs/live-voice.md index dd7c532c79..2d86703c00 100644 --- a/docs/live-voice.md +++ b/docs/live-voice.md @@ -1,39 +1,77 @@ # Codex Live Voice -codex-lb keeps Codex Live Voice call creation and its control sideband on the same ChatGPT account. This matters in an account pool: the account that successfully creates a call is the only account that can safely join its sideband. +codex-lb keeps Live Voice call creation and its control sideband on the same upstream ChatGPT account. This preserves call ownership when several upstream accounts share one pool. !!! note "Private Codex compatibility" - This capability supports the private routes used by the installed Codex app. It does not implement OpenAI's public Realtime API, `POST /v1/realtime/calls`, or `POST /v1/realtime/client_secrets`, and it does not proxy WebRTC media. + This capability covers the private routes used by Codex. WebRTC media remains peer-to-peer. -## Requirement: a registered proxy key +## Caller authentication -Live Voice routes always require an existing registered [proxy API key](api-keys.md), even when ordinary proxy API-key authentication is disabled. Missing or unregistered keys are rejected before codex-lb selects or contacts an upstream account. +The same private routes accept two caller types: -No new `CODEX_LB_*` setting, migration, dependency, or setup step is required. Operators who do not use Live Voice can continue running the base proxy and dashboard unchanged. +- Registered [proxy API keys](api-keys.md) keep their existing account assignments, limits, attribution, and affinity behavior. +- Official Codex OAuth credentials use the global policy under **Settings → Live Voice**. Every verified OAuth principal shares the configured upstream account pool. -## Supported private routes +An OAuth caller can remain independent from the imported upstream accounts. codex-lb validates its bearer and `chatgpt-account-id` against OpenAI, derives a stable principal for call ownership, and selects the serving account only from the global pool. The policy starts disabled and requires at least one selected upstream account before activation. -A compatible Codex client uses these routes as one account-bound workflow: +## Built-in OpenAI provider (OAuth) -- `POST /backend-api/codex/realtime/calls` creates the call. -- `WS /backend-api/codex/{call_id}` joins through the current installed-app form for bounded `rtc_...` or canonical UUID call ids; unrelated Codex WebSocket paths keep their ordinary behavior. -- `WS /v1/live/{call_id}` joins through the v3 form. -- `WS /v1/realtime?call_id={call_id}` joins through the legacy form. +Use this profile when Codex must retain the built-in `openai` provider. It keeps official ChatGPT OAuth for conversations and Live Voice and requires no Codex-LB API Key in the client. -codex-lb validates the call id returned in the successful call-creation `Location`, ignoring private query or fragment context after the first `?`, binds it to the final successful account under the caller's proxy key, and routes every supported sideband form back to that exact account. Attachment fails closed if the key, assignment, account state, or ownership binding is no longer valid; codex-lb does not refresh credentials or substitute another account after a call is created. +Enable **Settings → Live Voice → OAuth Live access**, select the upstream Accounts allowed to carry these calls, and route both Live Voice legs to codex-lb: -## Privacy and request history +```toml +model_provider = "openai" +experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:2455/backend-api/codex" +experimental_realtime_ws_base_url = "http://127.0.0.1:2455/v1" +``` -The ownership record contains only an API-key-scoped digest and the owning account reference. Raw call ids, proxy keys, OAuth tokens, SDP, attestation values, and realtime frame bodies are not stored in that record. Call-creation SDP is excluded from payload traces, and sideband frames are not added to Responses archives. +## Registered Proxy API Key -The dashboard's Recent Requests data accepts the sideband as a typed `realtime_live` WebSocket request. Private call-creation and sideband rows omit account identity, model content, upstream error text, failure metadata, live query text, and credentials. The internal ownership record stays hidden from ordinary sticky-session lists and delete operations. +Use this existing profile when the client should follow one registered Key's assignments, limits, and attribution. `requires_openai_auth = true` keeps the Codex app's ChatGPT account capabilities and Live Voice entry visible. `env_key` supplies the Codex-LB bearer used by conversations and Live routes. -## Failure behavior +```toml +model_provider = "codex-lb" +experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:2455/backend-api/codex" +experimental_realtime_ws_base_url = "http://127.0.0.1:2455/v1" + +[model_providers.codex-lb] +name = "openai" +base_url = "http://127.0.0.1:2455/backend-api/codex" +wire_api = "responses" +env_key = "CODEX_LB_API_KEY" +supports_websockets = true +requires_openai_auth = true +``` + +```bash +export CODEX_LB_API_KEY="sk-clb-..." +``` + +This profile uses the registered-Key lane and remains independent from the OAuth Live policy on the Settings page. + +## Client route compatibility + +The current client appends `/realtime/calls` to the WebRTC base and `/realtime?intent=...&call_id=...` to the WebSocket base. Repeat a real route probe after each bundled Codex upgrade because these keys remain experimental. -- `401 invalid_api_key` means the request did not carry a registered proxy key. -- `400 invalid_realtime_call_id` means the sideband supplied a malformed or ambiguous call id. -- `503 realtime_call_binding_failed` means a successful upstream call could not be bound safely; codex-lb does not replay that call through another account. +## Supported private routes + +- `POST /backend-api/codex/realtime/calls` +- `WS /backend-api/codex/{call_id}` +- `WS /v1/live/{call_id}` +- `WS /v1/realtime?call_id={call_id}` + +After call creation succeeds, codex-lb binds the returned call id to the final serving account under the authenticated caller scope. Every sideband form reloads that exact owner and confirms the current caller policy still allows it. Policy revocation, owner removal, unavailable accounts, and ownership mismatch all fail closed. + +## Privacy and request history + +Ownership records contain a caller-scoped digest and the owning account reference. Credentials, raw call ids, SDP, attestation values, realtime frames, audio, and transcripts stay out of persistence and request payload traces. OAuth Live request logs use nullable API-key attribution. + +## Failure behavior ---- +- `401 invalid_api_key`: caller authentication failed. +- `403 oauth_live_not_enabled`: the global OAuth Live policy is inactive or has no active eligible account. +- `400 invalid_realtime_call_id`: the sideband supplied an invalid call id. +- `503 realtime_call_binding_failed`: a successful upstream call could not be bound safely. -*Spec: [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat)* +*Specs: [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat) · [account-identity](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/account-identity) · [database-migrations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/database-migrations) · [frontend-architecture](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/frontend-architecture)* diff --git a/frontend/screenshots/capture.spec.ts b/frontend/screenshots/capture.spec.ts index 480e48c5fc..99b29b162a 100644 --- a/frontend/screenshots/capture.spec.ts +++ b/frontend/screenshots/capture.spec.ts @@ -30,6 +30,10 @@ const SCREENSHOT_PORT = process.env.SCREENSHOT_PORT ?? "4173"; const BASE_URL = process.env.SCREENSHOT_BASE_URL ?? `http://localhost:${SCREENSHOT_PORT}`; const THEME_KEY = "codex-lb-theme"; const SETTLE_MS = 1500; +const oauthLivePolicy = { + isActive: true, + allowedAccountIds: [accounts[0].accountId, accounts[2].accountId], +}; // CSS injected before page load to skip all animations/transitions instantly. const DISABLE_ANIMATIONS_CSS = ` @@ -89,6 +93,9 @@ async function interceptApi( } if (p === "/api/settings") return fulfill(route, settings); if (p === "/api/settings/upstream-proxy") return fulfill(route, upstreamProxyAdmin); + if (p === "/api/oauth-live-policy" || p === "/api/oauth-live-policy/") { + return fulfill(route, oauthLivePolicy); + } const usageResetCreditsMatch = p.match(/^\/api\/accounts\/([^/]+)\/usage-reset-credits$/); if (usageResetCreditsMatch) { const accountId = decodeURIComponent(usageResetCreditsMatch[1]); diff --git a/frontend/src/features/api-keys/components/account-multi-select.test.tsx b/frontend/src/features/api-keys/components/account-multi-select.test.tsx index 87d308461b..0565c67ff8 100644 --- a/frontend/src/features/api-keys/components/account-multi-select.test.tsx +++ b/frontend/src/features/api-keys/components/account-multi-select.test.tsx @@ -37,6 +37,9 @@ describe("AccountMultiSelect", () => { expect(await screen.findByText("5h 82% left")).toBeInTheDocument(); expect(screen.getByText("7d 67% left")).toBeInTheDocument(); + const quotaOption = screen.getByRole("menuitemcheckbox", { name: /quota@example\.com/i }); + expect(quotaOption).toHaveClass("[&>span:first-child]:top-[11px]"); + expect(quotaOption).toHaveClass("[&>span:first-child]:translate-y-0"); expect(screen.queryByText(/GPT-5\.3-Codex-Spark/i)).not.toBeInTheDocument(); }); @@ -54,6 +57,48 @@ describe("AccountMultiSelect", () => { }); }); + it("keeps an empty explicit selection empty and hides the all-accounts choice", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + await user.click(await screen.findByRole("button", { name: "Select allowed accounts" })); + + expect(screen.queryByRole("menuitemcheckbox", { name: "All accounts" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("menuitemcheckbox", { name: /primary@example\.com/i })); + expect(onChange).toHaveBeenCalledWith(["acc_primary"]); + }); + + it("renders a compact email-only list with right-aligned selection marks", async () => { + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(await screen.findByRole("button", { name: "1 account selected" })); + + const option = screen.getByRole("menuitemcheckbox", { name: "primary@example.com" }); + expect(option).toHaveClass("[&>span:first-child]:right-2"); + expect(option).toHaveClass("[&>span:first-child]:top-1/2"); + expect(screen.queryByText("Pro")).not.toBeInTheDocument(); + expect(screen.queryByText(/left$/i)).not.toBeInTheDocument(); + expect(screen.queryByText("primary@example.com", { selector: "[data-slot='badge']" })).not.toBeInTheDocument(); + }); + it("pluralizes the selected account count", async () => { renderWithProviders( , diff --git a/frontend/src/features/api-keys/components/account-multi-select.tsx b/frontend/src/features/api-keys/components/account-multi-select.tsx index b695a9c7e0..147b07e06a 100644 --- a/frontend/src/features/api-keys/components/account-multi-select.tsx +++ b/frontend/src/features/api-keys/components/account-multi-select.tsx @@ -28,6 +28,9 @@ export type AccountMultiSelectProps = { ariaDescribedBy?: string; triggerClassName?: string; allowPausedAccounts?: boolean; + selectionMode?: "all-or-subset" | "explicit"; + presentation?: "rich" | "compact"; + disabled?: boolean; }; type LimitChip = { key: string; @@ -142,6 +145,9 @@ export function AccountMultiSelect({ ariaDescribedBy, triggerClassName, allowPausedAccounts = false, + selectionMode = "all-or-subset", + presentation = "rich", + disabled = false, }: AccountMultiSelectProps) { const { t } = useTranslation(); const placeholderLabel = placeholder ?? t("apiKeys.accountSelect.all"); @@ -195,10 +201,6 @@ export function AccountMultiSelect({ [onChange, value], ); - const selectAll = useCallback(() => { - onChange([]); - }, [onChange]); - const label = value.length === 0 ? placeholderLabel : t("apiKeys.accountSelect.selected", { count: value.length }); @@ -213,7 +215,7 @@ export function AccountMultiSelect({ aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className={cn("w-full justify-between font-normal", triggerClassName)} - disabled={accountsQuery.isLoading} + disabled={disabled || accountsQuery.isLoading} > {accountsQuery.isLoading ? t("apiKeys.accountSelect.loading") : label} @@ -233,23 +235,35 @@ export function AccountMultiSelect({ /> - event.preventDefault()} - > - {t("apiKeys.accountSelect.all")} - - + {selectionMode === "all-or-subset" ? ( + <> + onChange([])} + onSelect={(event) => event.preventDefault()} + > + {t("apiKeys.accountSelect.all")} + + + + ) : null} {filtered.map((account) => ( toggle(account.accountId)} onSelect={(event) => event.preventDefault()} - className="items-start" + className={cn( + presentation === "compact" + ? "pl-2 pr-8 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&>span:first-child]:top-1/2 [&>span:first-child]:-translate-y-1/2" + : "items-start [&>span:first-child]:top-[11px] [&>span:first-child]:translate-y-0", + )} > - + {presentation === "compact" ? ( + {account.email} + ) : ( + + )} ))} {filtered.length === 0 ? ( @@ -258,7 +272,7 @@ export function AccountMultiSelect({ - {selectedAccounts.length > 0 ? ( + {presentation === "rich" && selectedAccounts.length > 0 ? (
{selectedAccounts.map((account) => ( @@ -266,6 +280,7 @@ export function AccountMultiSelect({ +
+ + + + ); +} diff --git a/frontend/src/features/settings/components/settings-page.test.tsx b/frontend/src/features/settings/components/settings-page.test.tsx index 66cfa4812e..9cc809032f 100644 --- a/frontend/src/features/settings/components/settings-page.test.tsx +++ b/frontend/src/features/settings/components/settings-page.test.tsx @@ -19,6 +19,7 @@ const quotaPlannerSectionMock = vi.fn(); const stickySessionsSectionMock = vi.fn(); const modelSourcesSettingsMock = vi.fn(); const dataRetentionSettingsMock = vi.fn(); +const oauthLiveSettingsMock = vi.fn(); vi.mock("@/features/settings/hooks/use-settings", () => ({ useSettings: () => useSettingsMock(), @@ -76,6 +77,13 @@ vi.mock("@/features/settings/components/data-retention-settings", () => ({ }, })); +vi.mock("@/features/settings/components/oauth-live-settings", () => ({ + OAuthLiveSettings: (props: unknown) => { + oauthLiveSettingsMock(props); + return
OAuth Live Settings
; + }, +})); + vi.mock("@/features/api-keys/components/api-keys-section", () => ({ ApiKeysSection: (props: unknown) => { apiKeysSectionMock(props); @@ -162,6 +170,7 @@ describe("SettingsPage", () => { stickySessionsSectionMock.mockReset(); modelSourcesSettingsMock.mockReset(); dataRetentionSettingsMock.mockReset(); + oauthLiveSettingsMock.mockReset(); }); async function expandAdvancedSettings() { @@ -191,7 +200,16 @@ describe("SettingsPage", () => { // Core sections stay visible without any interaction. expect(screen.getByText("Appearance Settings")).toBeInTheDocument(); expect(screen.getByText("Import Settings")).toBeInTheDocument(); - expect(screen.getByText("API Keys Section")).toBeInTheDocument(); + const apiKeysSection = screen.getByText("API Keys Section"); + const oauthLiveSection = screen.getByText("OAuth Live Settings"); + const advancedSettingsTrigger = screen.getByRole("button", { name: "Show advanced settings" }); + expect(apiKeysSection.compareDocumentPosition(oauthLiveSection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(oauthLiveSection.compareDocumentPosition(advancedSettingsTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(oauthLiveSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + readOnly: false, + }), + ); }); it("mounts every advanced section after one expand interaction", async () => { @@ -219,6 +237,7 @@ describe("SettingsPage", () => { expect(screen.queryByText("Session Settings")).not.toBeInTheDocument(); expect(importSettingsMock).toHaveBeenCalledWith(expect.objectContaining({ busy: true })); expect(apiKeysSectionMock).toHaveBeenCalledWith(expect.objectContaining({ disabled: true })); + expect(oauthLiveSettingsMock).toHaveBeenCalledWith(expect.objectContaining({ readOnly: true })); await expandAdvancedSettings(); diff --git a/frontend/src/features/settings/components/settings-page.tsx b/frontend/src/features/settings/components/settings-page.tsx index 876683690d..3d5dfafd8c 100644 --- a/frontend/src/features/settings/components/settings-page.tsx +++ b/frontend/src/features/settings/components/settings-page.tsx @@ -8,6 +8,7 @@ import { ApiKeysSection } from "@/features/api-keys/components/api-keys-section" import { useAccounts } from "@/features/accounts/hooks/use-accounts"; import { FirewallSection } from "@/features/firewall/components/firewall-section"; import { ModelSourcesSettings } from "@/features/model-sources/components/model-sources-settings"; +import { OAuthLiveSettings } from "@/features/settings/components/oauth-live-settings"; import { QuotaPlannerSection } from "@/features/quota-planner/components/quota-planner-section"; import { buildSettingsUpdateRequest } from "@/features/settings/payload"; import { AdvancedSettingsGroup } from "@/features/settings/components/advanced-settings-group"; @@ -136,6 +137,8 @@ export function SettingsPage() { } /> + + updateOAuthLivePolicy(payload), + onSuccess: (data) => { + queryClient.setQueryData(OAUTH_LIVE_POLICY_QUERY_KEY, data); + toast.success(t("settings.oauthLive.saved")); + }, + onError: (error: Error) => { + toast.error(error.message || t("settings.oauthLive.saveFailed")); + }, + }); + + return { policyQuery, updateMutation }; +} diff --git a/frontend/src/features/settings/oauth-live-api.ts b/frontend/src/features/settings/oauth-live-api.ts new file mode 100644 index 0000000000..715371630f --- /dev/null +++ b/frontend/src/features/settings/oauth-live-api.ts @@ -0,0 +1,17 @@ +import { get, put } from "@/lib/api-client"; + +import { + OAuthLivePolicySchema, + OAuthLivePolicyUpdateRequestSchema, +} from "@/features/settings/schemas"; + +const OAUTH_LIVE_POLICY_PATH = "/api/oauth-live-policy"; + +export function getOAuthLivePolicy() { + return get(OAUTH_LIVE_POLICY_PATH, OAuthLivePolicySchema); +} + +export function updateOAuthLivePolicy(payload: unknown) { + const validated = OAuthLivePolicyUpdateRequestSchema.parse(payload); + return put(OAUTH_LIVE_POLICY_PATH, OAuthLivePolicySchema, { body: validated }); +} diff --git a/frontend/src/features/settings/schemas.ts b/frontend/src/features/settings/schemas.ts index cdd65fb92e..ae03b524cc 100644 --- a/frontend/src/features/settings/schemas.ts +++ b/frontend/src/features/settings/schemas.ts @@ -51,6 +51,16 @@ const WeeklyPaceSmoothingMinutesSchema = z.union([ z.literal(240), ]); +export const OAuthLivePolicySchema = z.object({ + isActive: z.boolean(), + allowedAccountIds: z.array(z.string()), +}); + +export const OAuthLivePolicyUpdateRequestSchema = z.object({ + isActive: z.boolean(), + allowedAccountIds: z.array(z.string()), +}); + export const DashboardSettingsSchema = z .object({ stickyThreadsEnabled: z.boolean(), @@ -251,6 +261,11 @@ export const SettingsUpdateRequestSchema = z } }); +export type OAuthLivePolicy = z.infer; +export type OAuthLivePolicyUpdateRequest = z.infer< + typeof OAuthLivePolicyUpdateRequestSchema +>; + type ParsedDashboardSettings = z.infer; type StickyThresholdPresenceFlags = Pick< ParsedDashboardSettings, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b0638b5561..4424ebe09a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -978,6 +978,19 @@ "settings.page.trustedHeaderNotice": "Dashboard access is authenticated by a trusted reverse-proxy header. Password and TOTP stay available only as optional fallback login.", "settings.page.disabledNotice": "Dashboard auth is fully bypassed by configuration. Only use this mode behind network restrictions or external access control.", "settings.page.savingLabel": "Saving settings...", + "settings.oauthLive.title": "Live Voice", + "settings.oauthLive.description": "Route every Codex Live Voice caller verified by official OAuth through one shared account pool.", + "settings.oauthLive.pool.label": "Allowed upstream accounts", + "settings.oauthLive.pool.description": "The shared upstream pool eligible to own new OAuth Live Voice calls.", + "settings.oauthLive.pool.placeholder": "Select allowed accounts", + "settings.oauthLive.toggle.label": "OAuth Live access", + "settings.oauthLive.toggle.description": "Allow verified official OAuth identities to create and control Live Voice calls.", + "settings.oauthLive.scopeNote": "This policy applies to every verified OAuth caller. API keys keep their own account assignments.", + "settings.oauthLive.emptyError": "Select at least one allowed account before enabling OAuth Live Voice.", + "settings.oauthLive.enableAria": "Enable OAuth Live Voice", + "settings.oauthLive.loadFailed": "Failed to load OAuth Live Voice policy", + "settings.oauthLive.saveFailed": "Failed to save OAuth Live Voice policy", + "settings.oauthLive.saved": "OAuth Live Voice policy saved", "settings.advanced.title": "Advanced settings", "settings.advanced.description": "Routing tuning, upstream proxies, model sources, firewall, quota planner, and sticky sessions.", "settings.advanced.show": "Show advanced settings", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index eb505171e6..4215e642b4 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -976,6 +976,19 @@ "settings.advanced.hide": "고급 설정 숨기기", "settings.advanced.show": "고급 설정 표시", "settings.advanced.title": "고급 설정", + "settings.oauthLive.title": "Live Voice", + "settings.oauthLive.description": "공식 OAuth로 검증된 모든 Codex Live Voice 호출자를 하나의 공유 Account pool로 라우팅합니다.", + "settings.oauthLive.pool.label": "허용된 upstream Account", + "settings.oauthLive.pool.description": "새 OAuth Live Voice 통화를 처리할 수 있는 공유 upstream Account pool입니다.", + "settings.oauthLive.pool.placeholder": "허용할 Account 선택", + "settings.oauthLive.toggle.label": "OAuth Live 접근", + "settings.oauthLive.toggle.description": "검증된 공식 OAuth identity가 Live Voice 통화를 생성하고 제어하도록 허용합니다.", + "settings.oauthLive.scopeNote": "이 policy는 검증된 모든 OAuth 호출자에게 적용됩니다. API key는 자체 Account 할당을 유지합니다.", + "settings.oauthLive.emptyError": "OAuth Live Voice를 활성화하기 전에 허용된 Account를 하나 이상 선택하세요.", + "settings.oauthLive.enableAria": "OAuth Live Voice 활성화", + "settings.oauthLive.loadFailed": "OAuth Live Voice policy를 불러오지 못했습니다", + "settings.oauthLive.saveFailed": "OAuth Live Voice policy 저장 실패", + "settings.oauthLive.saved": "OAuth Live Voice policy 저장됨", "settings.appearance.accountRows.both": "둘 다", "settings.appearance.accountRows.bothDescription": "두 quota 행을 모두 표시합니다.", "settings.appearance.accountRows.description": "간단 Account 보기에서 표시할 quota 행을 선택합니다.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index efb95b15f2..b064dbf9e8 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -978,6 +978,19 @@ "settings.page.trustedHeaderNotice": "仪表盘访问由可信反向代理头部鉴权。密码与 TOTP 仅作为可选的回退登录方式保留。", "settings.page.disabledNotice": "仪表盘鉴权已被配置完全跳过。请仅在网络受限或外部已有访问控制的场景下使用此模式。", "settings.page.savingLabel": "保存设置中...", + "settings.oauthLive.title": "Live Voice", + "settings.oauthLive.description": "所有通过官方 OAuth 验证的 Codex Live Voice 调用,统一从这里配置的账户池路由。", + "settings.oauthLive.pool.label": "允许使用的上游账户", + "settings.oauthLive.pool.description": "可承载 OAuth Live Voice 新通话的统一账户池。", + "settings.oauthLive.pool.placeholder": "选择允许账户", + "settings.oauthLive.toggle.label": "OAuth Live 权限", + "settings.oauthLive.toggle.description": "统一允许官方 OAuth 身份创建和控制 Live Voice 通话。", + "settings.oauthLive.scopeNote": "该策略作用于所有通过 OAuth 验证的调用者;API Key 继续使用各自的账户分配。", + "settings.oauthLive.emptyError": "启用 OAuth Live Voice 前至少选择一个允许账户。", + "settings.oauthLive.enableAria": "启用 OAuth Live Voice", + "settings.oauthLive.loadFailed": "OAuth Live Voice 策略加载失败", + "settings.oauthLive.saveFailed": "OAuth Live Voice 策略保存失败", + "settings.oauthLive.saved": "OAuth Live Voice 策略已保存", "settings.advanced.title": "高级设置", "settings.advanced.description": "路由调优、上游代理、模型源、防火墙、配额规划器和粘性会话。", "settings.advanced.show": "显示高级设置", diff --git a/frontend/src/test/mocks/handler-coverage.test.ts b/frontend/src/test/mocks/handler-coverage.test.ts index 4e5bd3702e..a2637925f7 100644 --- a/frontend/src/test/mocks/handler-coverage.test.ts +++ b/frontend/src/test/mocks/handler-coverage.test.ts @@ -35,6 +35,8 @@ const EXPECTED_ENDPOINTS = [ "GET /api/conversations/:conversationId", // accounts "GET /api/accounts", + "GET /api/oauth-live-policy", + "PUT /api/oauth-live-policy", "POST /api/accounts/import", "PATCH /api/accounts/:accountId", "POST /api/accounts/:accountId/pause", diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index 9077775ca4..3ef9b78db9 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -99,6 +99,11 @@ const AccountRoutingPolicyPayloadSchema = z.object({ routingPolicy: z.enum(["normal", "burn_first", "preserve"]), }); +const OAuthLivePolicyPayloadSchema = z.object({ + isActive: z.boolean(), + allowedAccountIds: z.array(z.string()), +}); + const SettingsPayloadSchema = z.looseObject({ stickyThreadsEnabled: z.boolean().optional(), upstreamStreamTransport: z @@ -243,6 +248,7 @@ async function parseJsonBody( type MockState = { accounts: AccountSummary[]; + oauthLivePolicy: { isActive: boolean; allowedAccountIds: string[] }; requestLogs: RequestLogEntry[]; conversations: ConversationEntry[]; conversationDetails: ConversationDetails[]; @@ -328,6 +334,7 @@ type MockState = { function createInitialState(): MockState { return { accounts: createDefaultAccounts(), + oauthLivePolicy: { isActive: false, allowedAccountIds: [] }, requestLogs: createDefaultRequestLogs(), conversations: createDefaultConversations(), conversationDetails: [ @@ -835,6 +842,22 @@ export const handlers = [ return HttpResponse.json({ accounts: state.accounts }); }), + http.get("/api/oauth-live-policy", () => { + return HttpResponse.json(state.oauthLivePolicy); + }), + + http.put("/api/oauth-live-policy", async ({ request }) => { + const payload = await parseJsonBody(request, OAuthLivePolicyPayloadSchema); + if (!payload || (payload.isActive && payload.allowedAccountIds.length === 0)) { + return HttpResponse.json( + { error: { code: "invalid_oauth_live_policy", message: "Invalid OAuth Live policy" } }, + { status: 400 }, + ); + } + state.oauthLivePolicy = payload; + return HttpResponse.json(state.oauthLivePolicy); + }), + http.post("/api/accounts/import", async () => { const sequence = state.accounts.length + 1; const created = createAccountSummary({ diff --git a/openspec/changes/add-oauth-live-voice-auth/.openspec.yaml b/openspec/changes/add-oauth-live-voice-auth/.openspec.yaml new file mode 100644 index 0000000000..e08b5f89a2 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/add-oauth-live-voice-auth/context.md b/openspec/changes/add-oauth-live-voice-auth/context.md new file mode 100644 index 0000000000..b753b3ed2c --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/context.md @@ -0,0 +1,21 @@ +# OAuth WebRTC Live Voice Context + +Normative behavior lives in this change's capability delta specs. This file records the client constraint and operator boundary. + +## Client constraint + +The built-in Codex `openai` provider sends official ChatGPT OAuth as request authorization. The client cannot attach a second Codex-LB Proxy API Key to that provider. The OAuth caller lane lets this first-party profile reach codex-lb while retaining the official provider identity, model behavior, account state, and Live Voice entry point. + +The existing registered-Key profile remains a custom provider named `openai`. It combines `requires_openai_auth = true` with `env_key = "CODEX_LB_API_KEY"`: OpenAI authentication keeps the Codex app's ChatGPT capabilities visible, and `env_key` supplies the Codex-LB bearer used by proxy routes. Normal conversations and Live Voice use the same registered Key contract. + +## Operator example + +1. An operator imports several upstream ChatGPT accounts through existing Codex-LB flows. +2. Under Settings → Live Voice, the operator enables OAuth Live Voice and selects the shared upstream Accounts allowed to serve OAuth calls. +3. Official Codex keeps `model_provider = "openai"` and routes WebRTC call creation to `http://127.0.0.1:2455/backend-api/codex` and sideband to `http://127.0.0.1:2455/v1`. +4. Codex-LB verifies the OAuth principal independently from the imported pool, selects within the configured set, binds the final call owner, and attaches sideband to that owner. +5. Registered Proxy API Key clients keep their existing assignments, limits, logs, affinity digests, and `requires_openai_auth = true` plus `env_key` Codex profile. + +## Operational boundary + +The policy controls access to pooled upstream accounts and stores no OAuth credentials. Cross-machine authorization-row handling remains outside this feature. `refresh_token_reused` remains an operator-managed recovery event. Logs and acceptance evidence use only routes, status, counts, hashes, and presence booleans. diff --git a/openspec/changes/add-oauth-live-voice-auth/design.md b/openspec/changes/add-oauth-live-voice-auth/design.md new file mode 100644 index 0000000000..e65724bae6 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/design.md @@ -0,0 +1,49 @@ +## Context + +Live Voice has two authenticated legs: HTTP call creation and a control sideband WebSocket. The serving upstream account must remain identical across both legs. Official Codex supplies one ChatGPT OAuth bearer plus `chatgpt-account-id`; Key-based clients supply a registered Codex-LB Proxy API Key. + +## Goals + +- Preserve `model_provider = "openai"` and official OAuth. +- Preserve the existing registered-Key Codex profile and all Key authorization semantics. +- Support OAuth callers that have no corresponding imported Account row. +- Apply one operator-managed upstream pool to every verified OAuth caller. +- Preserve Key behavior and exact affinity input. +- Keep ownership immutable, private, bounded, and fail closed. + +## Decisions + +### Classify bearer before authorization + +`sk-clb-` bearers use strict Proxy Key validation. Other bearers require `chatgpt-account-id` and enter OAuth verification. Both call-create and all three sideband routes share this resolver. + +### Separate caller identity from serving accounts + +The OAuth resolver validates the supplied credential pair against the upstream usage endpoint. A matching imported seat retains its internal Account id as affinity material. An external caller receives `principal:{stable_seat_claim}` from verified `chatgpt_user_id` or `sub`. Its usage check follows the configured default upstream proxy route when routing is enabled. + +The typed result contains `principal_id`, optional `caller_account_id` for usage integration, normalized ChatGPT account id, verified usage payload, and route. Raw credentials remain absent from logs and persistence. + +### Use one global policy + +`oauth_live_global_policy` contains singleton id `1`, active state, and timestamps. `oauth_live_global_policy_accounts` links it to imported serving Accounts. Dashboard writes replace the complete set transactionally. Active policy writes require a non-empty known set; runtime lookup filters to currently active Accounts. + +All verified OAuth principals share this pool. Each principal still receives isolated affinity material, so one principal cannot attach another principal's call after learning its call id. + +### Keep Key and OAuth lanes compatible + +Key callers keep `api_key.id` affinity, assignments, limits, reservations, last-used updates, and request-log attribution. Their Codex profile continues to use `requires_openai_auth = true` for app-visible ChatGPT capabilities and `env_key` for the registered Codex-LB bearer. OAuth callers use `oauth:{principal_id}`, select within the global pool, and write request logs with `api_key_id = NULL`. + +### Keep the Settings job singular + +The Live Voice card answers one operator question: which upstream Accounts may serve verified OAuth Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. Caller inputs and caller-to-account matching are absent from the UI. + +## Migration and rollback + +One revision creates the global singleton policy table and its allowed-account relationship table. The initial API state is disabled with an empty pool. Downgrade removes the relationship table before the singleton table. + +## Risks + +- OAuth validation adds an upstream usage call on cache miss; bounded caching and singleflight limit fan-out. +- External principals require a stable verified seat claim, preserving cross-refresh ownership. +- Experimental client route keys can drift, so each bundled Codex upgrade requires call-create, sideband, and audible Live E2E acceptance. +- Client profiles can appear healthy while only one product path works, so acceptance covers normal conversation and audible Live Voice for both OAuth and registered-Key modes. diff --git a/openspec/changes/add-oauth-live-voice-auth/proposal.md b/openspec/changes/add-oauth-live-voice-auth/proposal.md new file mode 100644 index 0000000000..ca374e684b --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/proposal.md @@ -0,0 +1,25 @@ +## Why + +Official Codex uses ChatGPT OAuth for both WebRTC call creation and the sideband WebSocket when the built-in `openai` provider is selected. That provider has no slot for a second Codex-LB Proxy API Key. Codex-LB needs an OAuth caller lane alongside its existing registered-Key lane so both supported client profiles can create and control Live Voice calls. + +## What Changes + +- Accept registered Proxy API Keys and verified ChatGPT OAuth principals on the four private Live Voice routes. +- Dispatch `sk-clb-` bearers through existing strict Key validation and other bearers through verified ChatGPT OAuth identity resolution. +- Validate OAuth credentials upstream, derive a stable principal independently from imported serving accounts, and retain bounded cache/singleflight behavior. +- Add one global OAuth Live policy with an explicit upstream account pool. +- Preserve Key assignments, limits, attribution, affinity input, and the documented `requires_openai_auth = true` plus `env_key` Codex profile. +- Add one compact Settings editor: global enable switch, shared upstream pool, and save action. +- Keep immutable exact-owner binding across call creation and sideband. +- Document complete built-in OAuth and registered-Key Codex profiles, including both experimental realtime route overrides. + +## Modified Capabilities + +- `realtime-api-compat`: dual Key/OAuth caller authentication and exact-owner sideband. +- `account-identity`: verified OAuth principals can exist independently from imported accounts. +- `database-migrations`: global singleton policy and allowed-account relationship. +- `frontend-architecture`: one Settings-level global policy editor. + +## Impact + +- OAuth identity resolution, realtime caller scope, policy persistence/API, Settings UI, one reversible migration, tests, and user documentation. diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md new file mode 100644 index 0000000000..db08357ae0 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Verified OAuth Live callers resolve to a stable principal + +The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. A matching imported seat MAY retain its internal Account id for affinity compatibility; a verified caller without an imported Account SHALL remain authorized through its independent principal. A credential without a stable claim SHALL require one unambiguous imported seat. + +#### Scenario: External OAuth caller is accepted + +- **GIVEN** valid OAuth credentials carry a stable seat claim and no imported Account matches them +- **WHEN** the caller reaches a Live Voice route +- **THEN** upstream credential validation succeeds through the configured default route +- **AND** the caller receives an independent stable principal + +#### Scenario: Imported caller uses its Account affinity + +- **GIVEN** a verified caller matches one imported seat +- **WHEN** the resolver derives its principal +- **THEN** the existing internal Account id remains the affinity material +- **AND** repeated Live requests retain a stable caller scope + +#### Scenario: Identity remains ambiguous + +- **WHEN** credentials expose no stable seat claim and cannot resolve one eligible imported seat +- **THEN** identity resolution fails before policy lookup and upstream account selection +- **AND** the response reveals no identity or candidate details + +### Requirement: OAuth identity validation is bounded and coalesced + +Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. + +#### Scenario: Concurrent validation misses are coalesced + +- **GIVEN** the same uncached bearer and normalized `chatgpt-account-id` reach OAuth identity validation concurrently +- **WHEN** upstream validation is still in flight +- **THEN** all callers await one shared validation +- **AND** cache keys and diagnostics expose no raw credential diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/database-migrations/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/database-migrations/spec.md new file mode 100644 index 0000000000..476f847000 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/specs/database-migrations/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: OAuth Live policy is a global singleton + +The database SHALL store at most one global OAuth Live policy with singleton id `1` and an explicit set of allowed upstream Accounts. Policy rows MUST contain no credential, caller identity, email, call id, SDP, or frame content. Allowed Account deletion SHALL cascade its relationship row while preserving the singleton policy. + +#### Scenario: New installation starts disabled + +- **WHEN** a database upgrades to the OAuth Live policy revision +- **THEN** the global policy API returns inactive with an empty pool +- **AND** Key-based Live behavior remains available + +#### Scenario: Downgrade removes the global policy schema + +- **WHEN** the global revision downgrades +- **THEN** the global relationship table is removed before the singleton table +- **AND** the prior schema head is restored diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md new file mode 100644 index 0000000000..7e1adb6e4f --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Settings manages one global OAuth Live policy + +The Settings page SHALL expose one Live Voice card with a global OAuth enable switch, an explicit allowed-upstream Account multi-select, and one save action. Active state with an empty pool MUST be blocked with actionable validation. Read-only users SHALL see current state while all mutations remain disabled. + +#### Scenario: Operator enables OAuth Live globally + +- **GIVEN** one or more selectable upstream Accounts +- **WHEN** a dashboard writer enables OAuth Live, selects Accounts, and saves +- **THEN** the app replaces the global policy atomically +- **AND** every verified OAuth principal uses that pool for subsequent call creation + +#### Scenario: Operator revokes global access + +- **WHEN** a dashboard writer disables and saves the policy +- **THEN** subsequent OAuth Live authorization fails before account selection +- **AND** registered Key callers retain their existing behavior + +#### Scenario: Read-only inspection + +- **WHEN** a read-only dashboard user views Live Voice settings +- **THEN** active state and selected upstream Accounts remain visible +- **AND** switch, selector, and save action remain disabled diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md new file mode 100644 index 0000000000..90e07090d0 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md @@ -0,0 +1,154 @@ +## MODIFIED Requirements + +### Requirement: Call creation binds the final account under an authenticated caller scope + +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a verified OAuth principal. A `sk-clb-` bearer SHALL use strict Key validation. OAuth SHALL require an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the authenticated caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. OAuth callers use the same digest formula with `oauth:{principal_id}` as scope material. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. + +#### Scenario: initial or replacement account creates the call + +- **GIVEN** a registered proxy key and one or more eligible accounts +- **WHEN** the initial account, a pre-visible failover account, or a refreshed account successfully returns `Location: /v1/realtime/calls/rtc_example` +- **THEN** the proxy binds `rtc_example` to the final successful account under that key +- **AND** it returns the upstream status, body, `Location`, and allowlisted response headers unchanged + +#### Scenario: call Location carries private query context + +- **WHEN** successful call creation returns an exact supported path followed by `?` query text and an optional fragment +- **THEN** the proxy binds only the bounded call id parsed from the path before the first `?` +- **AND** it neither persists nor logs the discarded query or fragment text + +#### Scenario: private call creation has no authenticated caller + +- **GIVEN** ordinary proxy authentication is disabled +- **WHEN** a caller omits authorization, supplies a malformed bearer, or supplies an unregistered `sk-clb-` Key to realtime call creation +- **THEN** the proxy rejects the request before selecting or contacting an upstream account +- **AND** it does not create an anonymous ownership namespace + +#### Scenario: verified OAuth principal creates a call + +- **GIVEN** verified OAuth credentials and an active global policy with eligible serving Accounts +- **WHEN** call creation succeeds +- **THEN** the final serving Account binds under the OAuth principal +- **AND** request logging uses nullable API-key attribution + +#### Scenario: registered Key remains compatible with Codex conversations + +- **GIVEN** a Codex provider uses `requires_openai_auth = true` and a registered Key through `env_key` +- **WHEN** the client sends ordinary conversation traffic or creates and attaches a Live call +- **THEN** existing Key assignments, limits, attribution, and caller-scoped ownership remain unchanged + +#### Scenario: global OAuth policy is inactive + +- **WHEN** valid OAuth credentials reach Live while the global policy is inactive or empty after active-account filtering +- **THEN** the route returns `403 oauth_live_not_enabled` before account selection + +#### Scenario: successful response cannot be bound + +- **WHEN** upstream returns success without a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{bounded_call_id}`, or durable owner binding fails +- **THEN** the proxy returns one `503` with code `realtime_call_binding_failed` +- **AND** it does not expose or replay the already-created upstream call through another account +- **AND** the single private call-creation request row is persisted with error status + +#### Scenario: ownership and cleanup remain bounded + +- **WHEN** insertion encounters the same digest and owner +- **THEN** it is idempotent +- **WHEN** insertion encounters a different owner for that digest +- **THEN** it preserves the original owner and fails closed +- **WHEN** a binding expires or opportunistic cleanup runs +- **THEN** expiry removal is conditional on the owner and timestamp observed as expired, or cleanup removes one bounded reserved-prefix batch +- **AND** unrelated sticky-session rows remain unchanged + +#### Scenario: private freshness diagnostics remain account-safe + +- **GIVEN** private call creation reaches account freshness or legacy account-id metadata backfill +- **WHEN** shared refresh cleanup or caller-local metadata persistence emits a warning +- **THEN** the warning contains no internal account identifier, exception text, or traceback +- **AND** shared refresh safety does not depend on whether an ordinary or private caller created the singleflight task + +### Requirement: Every sideband route uses the exact bound owner without refresh or failover + +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and verified OAuth principals. All three routes SHALL use the same caller resolver as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. OAuth sideband SHALL resolve ownership with `principal_id` and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. + +#### Scenario: returned Location joins through every supported ingress + +- **GIVEN** call creation returned a bound `rtc_...` or canonical UUID id +- **WHEN** the caller opens the current-app path, v3 path, or legacy query route +- **THEN** all three resolve and lease the same immutable owner +- **AND** current-app and v3 use `/v1/live/{call_id}` upstream +- **AND** legacy uses `/v1/realtime?&call_id={call_id}` with one final `call_id` + +#### Scenario: unrelated Codex WebSocket path remains outside Live + +- **WHEN** a caller opens a one-segment `/backend-api/codex/{value}` path whose value is neither a bounded `rtc_...` id nor a canonical UUID +- **THEN** the dedicated Live route does not match +- **AND** the request remains available to ordinary Codex WebSocket routing + +#### Scenario: path ingress also supplies a query call id + +- **WHEN** a caller opens the current-app or v3 path with any `call_id` query parameter, whether it matches or conflicts with the path id +- **THEN** the proxy rejects the handshake with `400 invalid_realtime_call_id` before owner resolution or upstream connection +- **AND** it does not silently choose, duplicate, or reorder either id + +#### Scenario: caller or owner policy changed + +- **WHEN** another caller knows the call id, the owner leaves the caller's current allowed scope, or the owner is missing, paused, deleted, capped, or unavailable +- **THEN** attachment fails closed without revealing or substituting the owner +- **AND** it neither refreshes credentials nor selects another account + +#### Scenario: refreshed call owner attaches with current identity + +- **GIVEN** call creation refreshed and persisted the final owner's token or identity while routing inputs remained cached +- **WHEN** the sideband attaches +- **THEN** the service fresh-loads that same leased owner from persistence +- **AND** the connector uses the current persisted bearer, account, installation, and route identity +- **AND** the stream lease is released exactly once + +### Requirement: Internal ownership and request logs honor dashboard contracts + +Reserved realtime ownership is internal continuity state. Ordinary sticky-session operator APIs MUST hide it and MUST NOT delete it through single, bulk, filtered, or delete-all operations. The dashboard `RequestLogsResponseSchema` MUST accept a persisted full request row whose `requestKind` is `realtime_live` and transport is `websocket`; it MUST retain a closed enum rather than weakening request kinds to arbitrary strings. + +OAuth Live rows SHALL persist `api_key_id = NULL`. Key rows SHALL retain their existing API-key attribution. Both forms SHALL preserve the same credential-safe payload exclusions. + +#### Scenario: reserved owner is not an operator session + +- **GIVEN** reserved realtime ownership and ordinary sticky-session rows exist +- **WHEN** an operator lists sessions or performs single, bulk, filtered, or delete-all cleanup +- **THEN** reserved rows are absent from list results and remain unchanged +- **AND** ordinary matching rows retain their existing behavior + +#### Scenario: Recent Requests consumes a live sideband row + +- **GIVEN** the backend returns a full persisted request row with `requestKind: "realtime_live"` and `transport: "websocket"` +- **WHEN** the dashboard parses the response +- **THEN** parsing succeeds with the typed `realtime_live` value preserved + +### Requirement: Private realtime compatibility preserves zero-config base behavior and public boundaries + +The capability MUST preserve existing Key configuration and registration. OAuth Live policy MUST default to inactive. It MUST add no dependency, dashboard navigation item, README section, `.env.example` line, public model entry, public `/v1/realtime/calls`, or public `/v1/realtime/client_secrets` implementation. Its user documentation MUST identify these routes as private Codex app compatibility rather than advertise a public Realtime API. The base proxy and dashboard MUST continue to start and operate with zero new setup. + +#### Scenario: operator does not use Live Voice + +- **WHEN** an operator starts the base proxy and dashboard without adding configuration for this capability +- **THEN** existing startup and ordinary proxy/dashboard behavior remain available +- **AND** no public model or documented public Realtime route advertises this private transport + +## ADDED Requirements + +### Requirement: Explicit first-party client routing remains documented + +Documentation SHALL present two complete Codex client profiles. The built-in OAuth profile SHALL retain `model_provider = "openai"`. The registered-Key profile SHALL retain `requires_openai_auth = true` with `env_key = "CODEX_LB_API_KEY"`. Both profiles SHALL identify `experimental_realtime_webrtc_call_base_url` for `/backend-api/codex` and `experimental_realtime_ws_base_url` for `/v1`. An inactive global OAuth policy SHALL leave ordinary proxy, dashboard, and Key Live behavior available. + +#### Scenario: Operator configures either supported Codex profile + +- **WHEN** an operator follows the built-in OAuth profile or the registered-Key profile +- **THEN** ordinary conversations use the selected provider contract +- **AND** call creation and sideband both route through codex-lb +- **AND** the OAuth Live policy controls only verified OAuth callers + +## RENAMED Requirements + +### Requirement: Realtime call creation binds the final account under a required proxy key + +- **FROM:** Realtime call creation binds the final account under a required proxy key +- **TO:** Call creation binds the final account under an authenticated caller scope diff --git a/openspec/changes/add-oauth-live-voice-auth/tasks.md b/openspec/changes/add-oauth-live-voice-auth/tasks.md new file mode 100644 index 0000000000..f97ffba438 --- /dev/null +++ b/openspec/changes/add-oauth-live-voice-auth/tasks.md @@ -0,0 +1,18 @@ +## 1. Contract + +- [x] 1.1 Define verified OAuth identity, global policy, dual-caller Live routing, exact-owner sideband, persistence, and Settings requirements. +- [x] 1.2 Document the complete built-in OAuth and registered-Key Codex profiles, including both experimental realtime route overrides. + +## 2. Implementation + +- [x] 2.1 Add the typed OAuth identity resolver with bounded caching, singleflight, expiry limits, and credential-safe errors. +- [x] 2.2 Add the global policy ORM, reversible migration, repository, service, schemas, Dashboard API, and audit event. +- [x] 2.3 Add the Settings Live Voice card with a global enable switch, upstream Account multi-select, translations, and validation. +- [x] 2.4 Add one HTTP/WS caller resolver that preserves Proxy Key behavior and authorizes OAuth callers through the global pool. +- [x] 2.5 Preserve exact-owner binding across call creation and all supported sideband routes. + +## 3. Verification and delivery + +- [x] 3.1 Cover identity caching, policy validation, migration round-trip, HTTP/three-WS auth, owner continuity, nullable logging, and Key compatibility. +- [x] 3.2 Pass real local Codex Desktop normal conversation and audible Live Voice with both official OAuth and a registered Proxy API Key, plus policy revoke and logging/privacy acceptance. +- [x] 3.3 Sync stable requirements to main specs/context and pass the local verification gates. diff --git a/openspec/specs/account-identity/spec.md b/openspec/specs/account-identity/spec.md index cb63ef1ef2..1418c3d10e 100644 --- a/openspec/specs/account-identity/spec.md +++ b/openspec/specs/account-identity/spec.md @@ -22,3 +22,37 @@ Dashboard account summaries MUST expose and render the upstream ChatGPT account - **THEN** it displays the ChatGPT account id - **AND** it does not display the generic unknown-workspace label +### Requirement: Verified OAuth Live callers resolve to a stable principal + +The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. A matching imported seat MAY retain its internal Account id for affinity compatibility; a verified caller without an imported Account SHALL remain authorized through its independent principal. A credential without a stable claim SHALL require one unambiguous imported seat. + +#### Scenario: External OAuth caller is accepted + +- **GIVEN** valid OAuth credentials carry a stable seat claim and no imported Account matches them +- **WHEN** the caller reaches a Live Voice route +- **THEN** upstream credential validation succeeds through the configured default route +- **AND** the caller receives an independent stable principal + +#### Scenario: Imported caller uses its Account affinity + +- **GIVEN** a verified caller matches one imported seat +- **WHEN** the resolver derives its principal +- **THEN** the existing internal Account id remains the affinity material +- **AND** repeated Live requests retain a stable caller scope + +#### Scenario: Identity remains ambiguous + +- **WHEN** credentials expose no stable seat claim and cannot resolve one eligible imported seat +- **THEN** identity resolution fails before policy lookup and upstream account selection +- **AND** the response reveals no identity or candidate details + +### Requirement: OAuth identity validation is bounded and coalesced + +Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. + +#### Scenario: Concurrent validation misses are coalesced + +- **GIVEN** the same uncached bearer and normalized `chatgpt-account-id` reach OAuth identity validation concurrently +- **WHEN** upstream validation is still in flight +- **THEN** all callers await one shared validation +- **AND** cache keys and diagnostics expose no raw credential diff --git a/openspec/specs/database-migrations/spec.md b/openspec/specs/database-migrations/spec.md index 0f72c33a1d..e6ee4265f9 100644 --- a/openspec/specs/database-migrations/spec.md +++ b/openspec/specs/database-migrations/spec.md @@ -240,3 +240,18 @@ Migration state inspection SHALL classify `alembic_version` revisions that are n - **WHEN** the upgrade runs - **THEN** it fails with the ahead-specific guidance rather than a generic unsupported-revision remap error +### Requirement: OAuth Live policy is a global singleton + +The database SHALL store at most one global OAuth Live policy with singleton id `1` and an explicit set of allowed upstream Accounts. Policy rows MUST contain no credential, caller identity, email, call id, SDP, or frame content. Allowed Account deletion SHALL cascade its relationship row while preserving the singleton policy. + +#### Scenario: New installation starts disabled + +- **WHEN** a database upgrades to the OAuth Live policy revision +- **THEN** the global policy API returns inactive with an empty pool +- **AND** Key-based Live behavior remains available + +#### Scenario: Downgrade removes the global policy schema + +- **WHEN** the global revision downgrades +- **THEN** the global relationship table is removed before the singleton table +- **AND** the prior schema head is restored diff --git a/openspec/specs/frontend-architecture/spec.md b/openspec/specs/frontend-architecture/spec.md index 0da0717919..f2b0b2e379 100644 --- a/openspec/specs/frontend-architecture/spec.md +++ b/openspec/specs/frontend-architecture/spec.md @@ -2383,3 +2383,26 @@ unchanged. - **GIVEN** the dashboard principal has role `admin` - **WHEN** the dashboard view selector opens - **THEN** it exposes both Request Logs and Conversations + +### Requirement: Settings manages one global OAuth Live policy + +The Settings page SHALL expose one Live Voice card with a global OAuth enable switch, an explicit allowed-upstream Account multi-select, and one save action. Active state with an empty pool MUST be blocked with actionable validation. Read-only users SHALL see current state while all mutations remain disabled. + +#### Scenario: Operator enables OAuth Live globally + +- **GIVEN** one or more selectable upstream Accounts +- **WHEN** a dashboard writer enables OAuth Live, selects Accounts, and saves +- **THEN** the app replaces the global policy atomically +- **AND** every verified OAuth principal uses that pool for subsequent call creation + +#### Scenario: Operator revokes global access + +- **WHEN** a dashboard writer disables and saves the policy +- **THEN** subsequent OAuth Live authorization fails before account selection +- **AND** registered Key callers retain their existing behavior + +#### Scenario: Read-only inspection + +- **WHEN** a read-only dashboard user views Live Voice settings +- **THEN** active state and selected upstream Accounts remain visible +- **AND** switch, selector, and save action remain disabled diff --git a/openspec/specs/realtime-api-compat/context.md b/openspec/specs/realtime-api-compat/context.md index d2fa4156e0..c0ddcd9251 100644 --- a/openspec/specs/realtime-api-compat/context.md +++ b/openspec/specs/realtime-api-compat/context.md @@ -2,47 +2,64 @@ ## Purpose and Scope -This capability preserves account continuity between private Codex Live Voice call creation and its control sideband in a pooled proxy. It covers the installed Codex app's private compatibility routes and the existing operator and dashboard contracts around them. It does not implement the documented public Realtime API or proxy WebRTC media. +This capability preserves account continuity between private Codex Live Voice call creation and its control sideband in a pooled proxy. It covers the installed Codex app's private compatibility routes and the operator contracts around them. WebRTC media remains peer-to-peer. See `openspec/specs/realtime-api-compat/spec.md` for normative requirements and `docs/live-voice.md` for the rendered user guide. +## Supported caller profiles + +| Profile | Client authorization | Serving-account scope | +| --- | --- | --- | +| Built-in `openai` | Official ChatGPT OAuth bearer plus `chatgpt-account-id` | Settings → Live Voice global OAuth pool | +| Registered Key | `sk-clb-` bearer from `env_key`; Codex keeps `requires_openai_auth = true` | Existing Key assignments and limits | + +The built-in profile preserves the official OpenAI provider and requires no client-side Codex-LB Key. The registered-Key profile preserves the established codex-lb contract for conversations and Live Voice. Both profiles route call creation and sideband through codex-lb. + ## Rationale and Decisions -- **Final success owns the call:** The generic Codex control request may refresh or fail over before returning a response. Ownership is captured only after the final successful account returns a supported call `Location`. -- **Possession is key-scoped:** A call id is not authorization. The registered proxy-key id participates in the ownership digest, so another key cannot attach even if it knows the id. -- **Ownership is durable but opaque:** The existing sticky-session store holds a bounded digest and owner reference in a reserved namespace. Raw call ids, credentials, SDP, attestation values, and frames are excluded. -- **Attachment is hard continuity:** Every ingress resolves the exact owner, rechecks current assignment and account state, loads current persisted identity, and acquires one stream lease. It does not refresh or select a replacement account. +- **Authentication dispatch is explicit:** `sk-clb-` bearers enter strict Proxy Key validation. Other bearers require verified ChatGPT OAuth identity. +- **Caller identity and serving accounts are separate:** OAuth validation derives a stable principal from verified claims. The global policy selects which imported Accounts may serve that principal. +- **Possession is caller-scoped:** A call id alone grants no access. The caller scope participates in the ownership digest, isolating Keys and OAuth principals from each other. +- **Final success owns the call:** The generic Codex control request may refresh or fail over before returning a response. Ownership is captured after the final successful account returns a supported call `Location`. +- **Ownership is durable and opaque:** The sticky-session store holds a bounded digest and owner reference in a reserved namespace. Raw call ids, credentials, SDP, attestation values, and frames remain outside persistence. +- **Attachment enforces hard continuity:** Every ingress resolves the exact owner, rechecks current caller scope and account state, loads current persisted identity, and acquires one stream lease. - **Protocols stay explicit:** Current-app and v3 ingress connect to `/v1/live/{call_id}`. Legacy ingress preserves remaining ordered query fields and appends one normalized `call_id` to `/v1/realtime`. -- **Private authorization does not add base setup:** Live Voice requires an existing registered proxy key even when ordinary proxy authentication is disabled. The untouched proxy and dashboard remain zero-config. -- **Public boundaries stay visible:** Related SDK-documented `POST /v1/realtime/calls` and `POST /v1/realtime/client_secrets` routes provide protocol context only and are not implemented by this capability. +- **Base setup remains zero-config:** OAuth Live starts disabled. Registered-Key Live keeps its existing configuration and behavior. ## Constraints - All ids, mappings, batches, waits, messages, close reasons, and cleanup work are bounded. -- Missing or invalid keys fail before account selection; attachment rechecks current assignment and capacity. -- SDP, audio, transcripts, attestation values, frame bodies, tokens, and raw call ids are absent from persistence and diagnostics. Private call creation also keeps AuthManager metadata and shared-refresh warnings account-safe; the process-global refresh singleflight uses content-free task diagnostics regardless of caller order. -- No new setting, dependency, migration, public model, dashboard navigation item, README section, `.env.example` entry, background scheduler, or public Realtime endpoint is introduced. -- The connector does not infer a protocol, synthesize `OpenAI-Beta` or `Sec-WebSocket-Protocol`, or interpret event payloads. Client-offered WebSocket subprotocols retain their exact order through transport negotiation; downstream receives only an upstream-selected offered value. -- Reserved ownership is hidden from and protected against ordinary sticky-session list and delete operations. +- Missing or invalid caller credentials fail before account selection. +- OAuth policy lookup returns only active imported Accounts from its explicit pool. +- SDP, audio, transcripts, attestation values, frame bodies, tokens, and raw call ids remain absent from persistence and diagnostics. +- The feature adds one reversible migration and one Settings card. It adds no environment setting, dependency, dashboard navigation item, README section, `.env.example` entry, background scheduler, public model, or public Realtime endpoint. +- The connector preserves client-offered WebSocket subprotocol order and returns only an upstream-selected offered value. +- Reserved ownership stays hidden from ordinary sticky-session list and delete operations. ## Failure Modes -- **Missing or unsupported successful `Location` or durable binding failure:** Persist the single private request row as an error, replace the unusable success with one credential-safe `503 realtime_call_binding_failed`, and never replay the created call through another account. +- **OAuth policy inactive or empty:** Deny OAuth Live before account selection with `403 oauth_live_not_enabled`. +- **Missing or unsupported successful `Location` or durable binding failure:** Persist one private error request row and return `503 realtime_call_binding_failed`. - **Conflicting immutable owner:** Preserve the original owner and fail closed. -- **Expired, cross-key, reassigned, paused, deleted, capped, or unavailable owner:** Deny attachment without substitution. -- **Routed handshake denial or network failure:** Preserve normalized safe context and do not replay or penalize the account; ordinary Responses network fallback remains unchanged. -- **Peer disconnect, oversize, cancellation, or close timeout:** Cancel owned work, bound both initial close and post-cancel drain, consume any late close-task result, close peers at most once, and release the lease once without waiting indefinitely for cancellation-resistant transport cleanup. -- **Reserved operator action:** Hide the row from lists and reject or skip single, bulk, filtered, and delete-all operations. +- **Expired ownership or owner outside the current caller scope:** Deny attachment without account substitution. +- **Routed handshake denial or network failure:** Preserve normalized safe context and avoid replaying or penalizing the account. +- **Peer disconnect, oversize, cancellation, or close timeout:** Cancel owned work, bound drain time, close peers at most once, and release the lease once. + +## Examples + +### Built-in OAuth profile + +1. Codex uses `model_provider = "openai"` and sends official ChatGPT OAuth credentials. +2. codex-lb validates a stable OAuth principal and loads the active global OAuth pool. +3. The final serving Account owns the new call under an OAuth-principal digest. +4. Sideband revalidates the principal and reconnects to that exact owner. -## Sanitized Example +### Registered-Key profile -1. Registered key `key_a` sends a call-creation request to `POST /backend-api/codex/realtime/calls`. -2. The final upstream account returns a successful response and `Location: /v1/realtime/calls/rtc_example`. -3. codex-lb stores only a key-scoped ownership digest for `rtc_example` and returns the valid response. -4. The app opens `WS /backend-api/codex/rtc_example`. -5. The shared sideband service reloads and leases the bound owner, then connects upstream to `/v1/live/rtc_example` with current persisted identity. -6. A credential-safe `realtime_live` WebSocket request remains visible in Recent Requests while the internal ownership row remains absent from sticky-session operator views. +1. Codex uses a custom provider named `openai`, `requires_openai_auth = true`, and `env_key = "CODEX_LB_API_KEY"`. +2. The registered Key keeps its existing account assignments, limits, request attribution, and affinity input. +3. Normal conversations and Live Voice calls traverse the same Key-authenticated proxy contract. ## Operational Notes -The capability ships as one zero-config unit and uses existing request-log retention and sticky-session storage. There is no new rollout or monitoring setting. If a binding is no longer valid, create a new call rather than attempting account substitution. The dashboard parser correction is user-visible and remains subject to the repository's media evidence gate. +Both realtime base URLs must point at codex-lb. Each bundled Codex upgrade receives a call-create route probe, sideband route probe, normal-conversation check, and audible Live Voice check. A revoked OAuth policy affects subsequent OAuth authorization; registered-Key traffic continues under its existing Key policy. diff --git a/openspec/specs/realtime-api-compat/spec.md b/openspec/specs/realtime-api-compat/spec.md index 49264368db..6f93d0494f 100644 --- a/openspec/specs/realtime-api-compat/spec.md +++ b/openspec/specs/realtime-api-compat/spec.md @@ -6,9 +6,9 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro ## Requirements -### Requirement: Realtime call creation binds the final account under a required proxy key +### Requirement: Call creation binds the final account under an authenticated caller scope -The proxy SHALL require a registered proxy API key for `POST /backend-api/codex/realtime/calls` even when ordinary proxy API-key authentication is disabled. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, it MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the proxy key, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, SDP, attestation value, or frame body. Private call-creation diagnostics, including caller-local AuthManager metadata work and process-global shared refresh work, MUST redact internal account identifiers and suppress exception details; shared refresh diagnostics MUST use the strict policy regardless of which caller creates the singleflight task. +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a verified OAuth principal. A `sk-clb-` bearer SHALL use strict Key validation. OAuth SHALL require an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the authenticated caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. OAuth callers use the same digest formula with `oauth:{principal_id}` as scope material. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. #### Scenario: initial or replacement account creates the call @@ -23,13 +23,31 @@ The proxy SHALL require a registered proxy API key for `POST /backend-api/codex/ - **THEN** the proxy binds only the bounded call id parsed from the path before the first `?` - **AND** it neither persists nor logs the discarded query or fragment text -#### Scenario: private call creation has no registered key +#### Scenario: private call creation has no authenticated caller - **GIVEN** ordinary proxy authentication is disabled -- **WHEN** a caller omits a key or supplies an unregistered key to realtime call creation +- **WHEN** a caller omits authorization, supplies a malformed bearer, or supplies an unregistered `sk-clb-` Key to realtime call creation - **THEN** the proxy rejects the request before selecting or contacting an upstream account - **AND** it does not create an anonymous ownership namespace +#### Scenario: verified OAuth principal creates a call + +- **GIVEN** verified OAuth credentials and an active global policy with eligible serving Accounts +- **WHEN** call creation succeeds +- **THEN** the final serving Account binds under the OAuth principal +- **AND** request logging uses nullable API-key attribution + +#### Scenario: registered Key remains compatible with Codex conversations + +- **GIVEN** a Codex provider uses `requires_openai_auth = true` and a registered Key through `env_key` +- **WHEN** the client sends ordinary conversation traffic or creates and attaches a Live call +- **THEN** existing Key assignments, limits, attribution, and caller-scoped ownership remain unchanged + +#### Scenario: global OAuth policy is inactive + +- **WHEN** valid OAuth credentials reach Live while the global policy is inactive or empty after active-account filtering +- **THEN** the route returns `403 oauth_live_not_enabled` before account selection + #### Scenario: successful response cannot be bound - **WHEN** upstream returns success without a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{bounded_call_id}`, or durable owner binding fails @@ -56,7 +74,7 @@ The proxy SHALL require a registered proxy API key for `POST /backend-api/codex/ ### Requirement: Every sideband route uses the exact bound owner without refresh or failover -The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` only to a registered proxy key and only for that key's live binding. All adapters MUST use one bounded call-id normalizer, current key-assignment check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and verified OAuth principals. All three routes SHALL use the same caller resolver as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. OAuth sideband SHALL resolve ownership with `principal_id` and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. #### Scenario: returned Location joins through every supported ingress @@ -80,7 +98,7 @@ The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id} #### Scenario: caller or owner policy changed -- **WHEN** another key knows the call id, the owner is no longer assigned, or the owner is missing, paused, deleted, capped, or unavailable +- **WHEN** another caller knows the call id, the owner leaves the caller's current allowed scope, or the owner is missing, paused, deleted, capped, or unavailable - **THEN** attachment fails closed without revealing or substituting the owner - **AND** it neither refreshes credentials nor selects another account @@ -132,6 +150,8 @@ The live connector MUST replace downstream proxy authorization, account identity Reserved realtime ownership is internal continuity state. Ordinary sticky-session operator APIs MUST hide it and MUST NOT delete it through single, bulk, filtered, or delete-all operations. The dashboard `RequestLogsResponseSchema` MUST accept a persisted full request row whose `requestKind` is `realtime_live` and transport is `websocket`; it MUST retain a closed enum rather than weakening request kinds to arbitrary strings. +OAuth Live rows SHALL persist `api_key_id = NULL`. Key rows SHALL retain their existing API-key attribution. Both forms SHALL preserve the same credential-safe payload exclusions. + #### Scenario: reserved owner is not an operator session - **GIVEN** reserved realtime ownership and ordinary sticky-session rows exist @@ -147,10 +167,21 @@ Reserved realtime ownership is internal continuity state. Ordinary sticky-sessio ### Requirement: Private realtime compatibility preserves zero-config base behavior and public boundaries -The capability MUST use existing configuration and key registration. It MUST add no setting, migration, dependency, dashboard navigation item, README section, `.env.example` line, public model entry, public `/v1/realtime/calls`, or public `/v1/realtime/client_secrets` implementation. Its user documentation MUST identify these routes as private Codex app compatibility rather than advertise a public Realtime API. Without a registered key the private routes are unavailable, while the untouched base proxy and dashboard MUST continue to start and operate with zero new setup. +The capability MUST preserve existing Key configuration and registration. OAuth Live policy MUST default to inactive. It MUST add no dependency, dashboard navigation item, README section, `.env.example` line, public model entry, public `/v1/realtime/calls`, or public `/v1/realtime/client_secrets` implementation. Its user documentation MUST identify these routes as private Codex app compatibility rather than advertise a public Realtime API. The base proxy and dashboard MUST continue to start and operate with zero new setup. #### Scenario: operator does not use Live Voice - **WHEN** an operator starts the base proxy and dashboard without adding configuration for this capability - **THEN** existing startup and ordinary proxy/dashboard behavior remain available - **AND** no public model or documented public Realtime route advertises this private transport + +### Requirement: Explicit first-party client routing remains documented + +Documentation SHALL present two complete Codex client profiles. The built-in OAuth profile SHALL retain `model_provider = "openai"`. The registered-Key profile SHALL retain `requires_openai_auth = true` with `env_key = "CODEX_LB_API_KEY"`. Both profiles SHALL identify `experimental_realtime_webrtc_call_base_url` for `/backend-api/codex` and `experimental_realtime_ws_base_url` for `/v1`. An inactive global OAuth policy SHALL leave ordinary proxy, dashboard, and Key Live behavior available. + +#### Scenario: Operator configures either supported Codex profile + +- **WHEN** an operator follows the built-in OAuth profile or the registered-Key profile +- **THEN** ordinary conversations use the selected provider contract +- **AND** call creation and sideband both route through codex-lb +- **AND** the OAuth Live policy controls only verified OAuth callers diff --git a/tests/integration/test_accounts_repository.py b/tests/integration/test_accounts_repository.py index 81d9b3ce0d..2c12d9b004 100644 --- a/tests/integration/test_accounts_repository.py +++ b/tests/integration/test_accounts_repository.py @@ -258,3 +258,38 @@ async def test_upsert_account_slot_adds_third_label_only_workspace_for_same_emai ("triton_workspace", "Triton"), ("atlas_workspace", "Atlas"), ] + + +@pytest.mark.asyncio +async def test_oauth_identity_candidates_include_limited_accounts_and_exclude_hard_blocked(db_setup): + del db_setup + shared_chatgpt_id = "chatgpt_oauth_live_candidates" + statuses = { + "active": AccountStatus.ACTIVE, + "rate_limited": AccountStatus.RATE_LIMITED, + "quota_exceeded": AccountStatus.QUOTA_EXCEEDED, + "paused": AccountStatus.PAUSED, + "reauth_required": AccountStatus.REAUTH_REQUIRED, + "deactivated": AccountStatus.DEACTIVATED, + } + + async with SessionLocal() as session: + accounts = [] + for name, status in statuses.items(): + account = _account( + f"oauth_{name}", + chatgpt_account_id=shared_chatgpt_id, + email=f"{name}@example.com", + ) + account.status = status + accounts.append(account) + session.add_all(accounts) + await session.commit() + + candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(shared_chatgpt_id) + + assert [candidate.id for candidate in candidates] == [ + "oauth_active", + "oauth_quota_exceeded", + "oauth_rate_limited", + ] diff --git a/tests/integration/test_auth_middleware.py b/tests/integration/test_auth_middleware.py index de3448bbcb..1fdc37ea00 100644 --- a/tests/integration/test_auth_middleware.py +++ b/tests/integration/test_auth_middleware.py @@ -1201,7 +1201,7 @@ async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: ob assert account_id == raw_chatgpt_account_id return UsagePayload.model_validate({"plan_type": "team"}) - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) await async_client.post("/api/dashboard-auth/logout", json={}) allowed = await async_client.get( @@ -1225,7 +1225,7 @@ async def test_codex_usage_blocks_unregistered_chatgpt_account_id(async_client, async def should_not_call_fetch_usage(**_: object) -> UsagePayload: raise AssertionError("fetch_usage should not be called for unknown chatgpt-account-id") - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", should_not_call_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", should_not_call_fetch_usage) await async_client.post("/api/dashboard-auth/logout", json={}) blocked = await async_client.get( diff --git a/tests/integration/test_codex_usage_api.py b/tests/integration/test_codex_usage_api.py index 95bf0593cf..ddb871698f 100644 --- a/tests/integration/test_codex_usage_api.py +++ b/tests/integration/test_codex_usage_api.py @@ -4,6 +4,7 @@ import pytest +from app.core.auth.codex_oauth_identity import clear_codex_oauth_identity_cache from app.core.clients.rate_limit_reset_credits import RateLimitResetCreditsSnapshot, ResetCreditItem from app.core.clients.usage import ConsumeRateLimitResetCreditResponse from app.core.crypto import TokenEncryptor @@ -75,12 +76,16 @@ async def _create_api_key(*, name: str, limits: list[LimitRuleInput] | None = No @pytest.fixture(autouse=True) def stub_codex_usage_caller_validation(monkeypatch): + clear_codex_oauth_identity_cache() + async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: object) -> UsagePayload: assert access_token == "chatgpt-token" assert account_id is not None return UsagePayload.model_validate({"plan_type": "plus"}) - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + yield + clear_codex_oauth_identity_cache() @pytest.mark.asyncio @@ -722,7 +727,7 @@ async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: ob } ) - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) response = await async_client.get( "/api/codex/usage", @@ -771,7 +776,7 @@ async def force_refresh( refreshed_account_ids.append(f"{account.id}:{ignore_refresh_disabled}:{access_token_override}") return True - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", stub_consume_rate_limit_reset_credit) monkeypatch.setattr("app.modules.proxy.api.UsageUpdater", StubUsageUpdater) cache_generation = get_account_selection_cache().generation @@ -849,7 +854,7 @@ async def force_refresh( refreshed_account_ids.append(account.id) return True - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", stub_consume_rate_limit_reset_credit) monkeypatch.setattr("app.modules.proxy.api.UsageUpdater", StubUsageUpdater) @@ -894,7 +899,7 @@ async def stub_fetch_usage(**_: object) -> UsagePayload: async def should_not_consume(**_: object) -> ConsumeRateLimitResetCreditResponse: raise AssertionError("empty redeem_request_id should not be forwarded upstream") - monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", should_not_consume) response = await async_client.post( diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 2d82cf4d01..980cf76574 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -1396,3 +1396,51 @@ def _schema_state(sync_conn): assert survivor == 3 finally: await engine.dispose() + + +@pytest.mark.asyncio +async def test_oauth_live_policy_migration_upgrade_and_downgrade(tmp_path): + from alembic import command + from sqlalchemy import inspect as sa_inspect + + from app.db.migrate import _build_alembic_config + + db_url = f"sqlite+aiosqlite:///{tmp_path / 'oauth-live-policy.sqlite'}" + parent_revision = "20260731_000000_add_capability_lineage_markers" + policy_revision = "20260803_000000_add_oauth_live_policies" + tables = {"oauth_live_global_policy", "oauth_live_global_policy_accounts"} + + def _schema_state(sync_conn): + inspector = sa_inspect(sync_conn) + present = {table for table in tables if inspector.has_table(table)} + allowed_indexes = ( + {index["name"] for index in inspector.get_indexes("oauth_live_global_policy_accounts")} + if "oauth_live_global_policy_accounts" in present + else set() + ) + return present, allowed_indexes + + await to_thread.run_sync(lambda: run_upgrade(db_url, parent_revision, bootstrap_legacy=False)) + engine = create_async_engine(db_url, future=True) + try: + async with engine.connect() as conn: + assert await conn.run_sync(_schema_state) == (set(), set()) + + await to_thread.run_sync(lambda: run_upgrade(db_url, policy_revision, bootstrap_legacy=False)) + async with engine.connect() as conn: + present, indexes = await conn.run_sync(_schema_state) + row_counts = { + table: (await conn.execute(text(f"SELECT COUNT(*) FROM {table}"))).scalar_one() for table in tables + } + assert present == tables + assert "ix_oauth_live_global_policy_accounts_allowed_account_id" in indexes + assert row_counts == {table: 0 for table in tables} + + await to_thread.run_sync(lambda: command.downgrade(_build_alembic_config(db_url), parent_revision)) + async with engine.connect() as conn: + assert await conn.run_sync(_schema_state) == (set(), set()) + + result = await to_thread.run_sync(lambda: run_upgrade(db_url, "head", bootstrap_legacy=False)) + assert result.current_revision == policy_revision + finally: + await engine.dispose() diff --git a/tests/integration/test_oauth_live_policy.py b/tests/integration/test_oauth_live_policy.py new file mode 100644 index 0000000000..018ffd53e2 --- /dev/null +++ b/tests/integration/test_oauth_live_policy.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from sqlalchemy import delete, select + +import app.modules.oauth_live.api as oauth_live_api_module +from app.core.crypto import TokenEncryptor +from app.db.models import Account, AccountStatus, OAuthLivePolicy, OAuthLivePolicyAccount +from app.db.session import SessionLocal +from app.modules.oauth_live.repository import GLOBAL_POLICY_ID, OAuthLivePolicyRepository + +pytestmark = pytest.mark.integration + + +def _account(account_id: str, *, status: AccountStatus = AccountStatus.ACTIVE) -> Account: + encryptor = TokenEncryptor() + return Account( + id=account_id, + chatgpt_account_id=f"workspace-{account_id}", + chatgpt_user_id=f"user-{account_id}", + email=f"{account_id}@example.com", + plan_type="plus", + access_token_encrypted=encryptor.encrypt(f"access-{account_id}"), + refresh_token_encrypted=encryptor.encrypt(f"refresh-{account_id}"), + id_token_encrypted=encryptor.encrypt(f"id-{account_id}"), + last_refresh=datetime.now(timezone.utc), + status=status, + ) + + +async def _seed_accounts(*accounts: Account) -> None: + async with SessionLocal() as session: + session.add_all(accounts) + await session.commit() + + +@pytest.mark.asyncio +async def test_global_policy_defaults_inactive_and_round_trips_assignments(async_client) -> None: + await _seed_accounts(_account("allowed-b"), _account("allowed-a")) + + missing = await async_client.get("/api/oauth-live-policy") + assert missing.status_code == 200 + assert missing.json() == { + "isActive": False, + "allowedAccountIds": [], + "createdAt": None, + "updatedAt": None, + } + + saved = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": False, "allowedAccountIds": ["allowed-b", "allowed-a", "allowed-b"]}, + ) + assert saved.status_code == 200 + body = saved.json() + assert body["isActive"] is False + assert body["allowedAccountIds"] == ["allowed-a", "allowed-b"] + assert body["createdAt"] is not None + assert body["updatedAt"] is not None + assert "callerAccountId" not in body + + fetched = await async_client.get("/api/oauth-live-policy") + assert fetched.status_code == 200 + assert fetched.json() == body + + +@pytest.mark.asyncio +async def test_global_policy_active_requires_explicit_nonempty_set_and_is_atomic(async_client) -> None: + await _seed_accounts(_account("allowed")) + seeded = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": True, "allowedAccountIds": ["allowed"]}, + ) + assert seeded.status_code == 200 + + rejected = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": True, "allowedAccountIds": []}, + ) + assert rejected.status_code == 400 + assert rejected.json()["error"]["code"] == "invalid_oauth_live_policy" + + preserved = await async_client.get("/api/oauth-live-policy") + assert preserved.json()["isActive"] is True + assert preserved.json()["allowedAccountIds"] == ["allowed"] + + +@pytest.mark.asyncio +async def test_global_policy_rejects_unknown_allowed_accounts(async_client) -> None: + rejected = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": False, "allowedAccountIds": ["missing"]}, + ) + assert rejected.status_code == 400 + assert rejected.json()["error"]["code"] == "invalid_oauth_live_policy" + + +@pytest.mark.asyncio +async def test_global_policy_update_writes_credential_safe_audit_metadata(async_client, monkeypatch) -> None: + await _seed_accounts(_account("allowed")) + audit_events: list[tuple[str, dict[str, object] | None]] = [] + + def record_audit(action: str, *, actor_ip=None, details=None) -> None: + del actor_ip + audit_events.append((action, details)) + + monkeypatch.setattr(oauth_live_api_module.AuditService, "log_async", record_audit) + + response = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": True, "allowedAccountIds": ["allowed"]}, + ) + assert response.status_code == 200 + assert audit_events == [ + ( + "oauth_live_policy_updated", + {"is_active": True, "allowed_account_count": 1}, + ) + ] + + +@pytest.mark.asyncio +async def test_runtime_policy_lookup_fails_closed_and_filters_inactive_accounts(db_setup) -> None: + del db_setup + await _seed_accounts( + _account("active-allowed"), + _account("inactive-allowed", status=AccountStatus.PAUSED), + ) + + async with SessionLocal() as session: + repository = OAuthLivePolicyRepository(session) + assert await repository.get_active_allowed_account_ids() == frozenset() + await repository.replace_policy(is_active=False, allowed_account_ids=["active-allowed"]) + assert await repository.get_active_allowed_account_ids() == frozenset() + await repository.replace_policy( + is_active=True, + allowed_account_ids=["active-allowed", "inactive-allowed"], + ) + assert await repository.get_active_allowed_account_ids() == frozenset({"active-allowed"}) + + +@pytest.mark.asyncio +async def test_allowed_account_deletion_cascades_assignment_and_keeps_global_policy(db_setup) -> None: + del db_setup + await _seed_accounts(_account("allowed")) + + async with SessionLocal() as session: + repository = OAuthLivePolicyRepository(session) + await repository.replace_policy(is_active=True, allowed_account_ids=["allowed"]) + await session.execute(delete(Account).where(Account.id == "allowed")) + await session.commit() + + async with SessionLocal() as session: + assert await session.get(OAuthLivePolicy, GLOBAL_POLICY_ID) is not None + assert (await session.execute(select(OAuthLivePolicyAccount))).scalars().all() == [] diff --git a/tests/integration/test_proxy_realtime_live.py b/tests/integration/test_proxy_realtime_live.py index efb573ada7..1623d03aa2 100644 --- a/tests/integration/test_proxy_realtime_live.py +++ b/tests/integration/test_proxy_realtime_live.py @@ -26,12 +26,13 @@ ProxyResponseError, ) from app.core.clients.proxy_websocket import UpstreamWebSocketMessage -from app.core.exceptions import ProxyAuthError +from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute from app.db.models import RequestLog from app.db.session import SessionLocal from app.dependencies import get_proxy_service_for_app from app.modules.proxy.account_cache import AccountSelectionCache +from app.modules.proxy.realtime_auth import OAuthLiveNotEnabledError, RealtimeCallerScope pytestmark = pytest.mark.integration @@ -62,7 +63,7 @@ async def allow_proxy_api_key(_authorization, **_kwargs): return None async def require_proxy_api_key(authorization): - if authorization != "Bearer live-key": + if authorization != "Bearer sk-clb-live-key": raise ProxyAuthError("Missing API key in Authorization header") return SimpleNamespace(id="live-api-key") @@ -168,11 +169,11 @@ async def fake_proxy_live( query_params, *, protocol, - api_key, + caller_scope, client_ip=None, ): del self - assert api_key.id == "live-api-key" + assert caller_scope.api_key.id == "live-api-key" calls.append( { "call_id": call_id, @@ -196,7 +197,7 @@ async def fake_proxy_live( headers={ "OpenAI-Alpha": "quicksilver=v2", "x-oai-attestation": "attestation", - "Authorization": "Bearer live-key", + "Authorization": "Bearer sk-clb-live-key", }, ) as websocket: assert websocket.receive_text() == "ready" @@ -222,6 +223,157 @@ async def fake_proxy_live( assert "quicksilver" not in access_messages[0] +@pytest.mark.parametrize( + ("error", "expected_status", "expected_code"), + [ + (ProxyAuthError("invalid caller"), 401, "invalid_api_key"), + (OAuthLiveNotEnabledError(), 403, "oauth_live_not_enabled"), + (ProxyRateLimitError("validation limited"), 429, "rate_limit_exceeded"), + (ProxyUpstreamError("validation unavailable"), 503, "upstream_error"), + ], +) +def test_realtime_sideband_serializes_typed_caller_denials( + app_instance, + monkeypatch: pytest.MonkeyPatch, + error: Exception, + expected_status: int, + expected_code: str, +) -> None: + async def reject_caller(*_args: object, **_kwargs: object): + raise error + + monkeypatch.setattr(proxy_api_module, "resolve_realtime_caller_scope", reject_caller) + + with TestClient(app_instance) as client: + with pytest.raises(WebSocketDenialResponse) as raised: + with client.websocket_connect( + "/v1/live/rtc_denied", + headers={ + "Authorization": "Bearer oauth-token", + "chatgpt-account-id": "workspace-id", + }, + ): + pass + + assert raised.value.status_code == expected_status + assert raised.value.json()["error"]["code"] == expected_code + + +@pytest.mark.asyncio +async def test_realtime_call_create_returns_oauth_policy_denial_before_selection( + async_client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def reject_caller(*_args: object, **_kwargs: object): + raise OAuthLiveNotEnabledError() + + async def fail_selection(*_args: object, **_kwargs: object): + raise AssertionError("account selection must remain unreachable") + + monkeypatch.setattr(proxy_api_module, "resolve_realtime_caller_scope", reject_caller) + monkeypatch.setattr(proxy_module.ProxyService, "codex_control_request", fail_selection) + + response = await async_client.post( + "/backend-api/codex/realtime/calls", + content=b"offer", + headers={ + "Authorization": "Bearer oauth-token", + "chatgpt-account-id": "workspace-id", + }, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "oauth_live_not_enabled" + + +@pytest.mark.asyncio +async def test_realtime_call_create_oauth_scope_selects_only_allowed_account( + async_client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + caller = await async_client.post( + "/api/accounts/import", + files={ + "auth_json": ( + "caller.json", + json.dumps(_auth_json("workspace-caller", "caller@example.com")), + "application/json", + ) + }, + ) + allowed = await async_client.post( + "/api/accounts/import", + files={ + "auth_json": ( + "allowed.json", + json.dumps(_auth_json("workspace-allowed", "allowed@example.com")), + "application/json", + ) + }, + ) + assert caller.status_code == 200 + assert allowed.status_code == 200 + caller_account_id = caller.json()["accountId"] + allowed_account_id = allowed.json()["accountId"] + scope = RealtimeCallerScope.for_oauth( + principal_id=caller_account_id, + allowed_account_ids={allowed_account_id}, + ) + + async def resolve_scope(*_args: object, **_kwargs: object) -> RealtimeCallerScope: + return scope + + upstream_account_ids: list[str | None] = [] + + async def create_call(*_args: object, account_id: str | None, **_kwargs: object) -> CodexControlResponse: + upstream_account_ids.append(account_id) + return CodexControlResponse( + status_code=201, + body=b"answer", + headers={"location": "/v1/realtime/calls/rtc_oauth_allowed"}, + ) + + monkeypatch.setattr(proxy_api_module, "resolve_realtime_caller_scope", resolve_scope) + monkeypatch.setattr(proxy_module, "core_codex_control_request", create_call) + service = get_proxy_service_for_app(async_client._transport.app) + original_write_request_log = service._write_request_log + request_log_calls: list[dict[str, object]] = [] + + async def capture_request_log(**kwargs: object) -> None: + request_log_calls.append(kwargs) + await cast(Any, original_write_request_log)(**kwargs) + + monkeypatch.setattr(service, "_write_request_log", capture_request_log) + + response = await async_client.post( + "/backend-api/codex/realtime/calls", + content=b"offer", + headers={ + "Authorization": "Bearer oauth-token", + "chatgpt-account-id": "workspace-caller", + }, + ) + + assert response.status_code == 201 + assert upstream_account_ids == ["workspace-allowed"] + assert request_log_calls[0]["api_key"] is None + request_log_id = cast(str, request_log_calls[0]["request_id"]) + deadline = asyncio.get_running_loop().time() + 1 + persisted = None + while persisted is None: + assert await service.drain_persistence_tasks(timeout_seconds=1) + async with SessionLocal() as session: + persisted = ( + await session.execute(select(RequestLog).where(RequestLog.request_id == request_log_id)) + ).scalar_one_or_none() + if persisted is not None: + break + if asyncio.get_running_loop().time() >= deadline: + raise AssertionError("OAuth realtime call-create request log was not persisted") + await asyncio.sleep(0.01) + assert persisted.api_key_id is None + + @pytest.mark.parametrize( ("path", "logged_path", "call_id"), [ @@ -295,7 +447,7 @@ async def accept_if_routed(_self, websocket, call_id, *_args, **_kwargs): def observe_rejection(client: TestClient, path: str) -> tuple[str, int | None, bytes | str | None]: try: - with client.websocket_connect(path, headers={"Authorization": "Bearer live-key"}): + with client.websocket_connect(path, headers={"Authorization": "Bearer sk-clb-live-key"}): pass except WebSocketDenialResponse as exc: return "denial", exc.status_code, exc.content @@ -358,7 +510,7 @@ async def fail_proxy_live(*_args, **_kwargs): with TestClient(app_instance) as client: with pytest.raises(WebSocketDenialResponse) as raised: - with client.websocket_connect(path, headers={"Authorization": "Bearer live-key"}): + with client.websocket_connect(path, headers={"Authorization": "Bearer sk-clb-live-key"}): pass assert raised.value.status_code == 400 @@ -383,7 +535,7 @@ async def reject_if_called(_self, websocket, *_args, **_kwargs): with pytest.raises((WebSocketDenialResponse, WebSocketDisconnect)): with client.websocket_connect( "/backend-api/codex/ordinary-control", - headers={"Authorization": "Bearer live-key"}, + headers={"Authorization": "Bearer sk-clb-live-key"}, ): pass @@ -415,8 +567,18 @@ def test_constrained_live_routes_enter_live_service_with_raw_call_id( ) -> None: calls: list[str] = [] - async def fake_proxy_live(self, websocket, call_id, headers, query_params, *, protocol, api_key, client_ip=None): - del self, headers, query_params, protocol, api_key, client_ip + async def fake_proxy_live( + self, + websocket, + call_id, + headers, + query_params, + *, + protocol, + caller_scope, + client_ip=None, + ): + del self, headers, query_params, protocol, caller_scope, client_ip calls.append(call_id) await websocket.accept() await websocket.close(code=1000) @@ -424,7 +586,7 @@ async def fake_proxy_live(self, websocket, call_id, headers, query_params, *, pr monkeypatch.setattr(proxy_module.ProxyService, "proxy_realtime_live_websocket", fake_proxy_live) with TestClient(app_instance) as client: - with client.websocket_connect(path, headers={"Authorization": "Bearer live-key"}): + with client.websocket_connect(path, headers={"Authorization": "Bearer sk-clb-live-key"}): pass assert calls == [expected_call_id] @@ -465,7 +627,7 @@ async def reject_if_called(_self, websocket, *_args, **_kwargs): with TestClient(app_instance) as client: with pytest.raises((WebSocketDenialResponse, WebSocketDisconnect)): - with client.websocket_connect(path, headers={"Authorization": "Bearer live-key"}): + with client.websocket_connect(path, headers={"Authorization": "Bearer sk-clb-live-key"}): pass assert service_called is False @@ -821,7 +983,7 @@ async def fail_setup(*_args, **_kwargs): with pytest.raises(WebSocketDenialResponse) as denied: with client.websocket_connect( "/v1/live/rtc_setup_failure", - headers={"Authorization": "Bearer live-key"}, + headers={"Authorization": "Bearer sk-clb-live-key"}, ): pass @@ -1153,7 +1315,7 @@ async def missing_owner(self, call_id, *, api_key): with pytest.raises(WebSocketDenialResponse) as raised: with client.websocket_connect( "/v1/live/rtc_missing", - headers={"Authorization": "Bearer live-key"}, + headers={"Authorization": "Bearer sk-clb-live-key"}, ): pass @@ -1179,7 +1341,7 @@ def test_realtime_sideband_websocket_rejects_malformed_call_id_before_selection( with pytest.raises(WebSocketDenialResponse) as raised: with client.websocket_connect( path, - headers={"Authorization": "Bearer live-key"}, + headers={"Authorization": "Bearer sk-clb-live-key"}, ): pass @@ -1204,7 +1366,7 @@ async def reject_if_called(_self, websocket, *_args, **_kwargs): with pytest.raises((WebSocketDenialResponse, WebSocketDisconnect)): with client.websocket_connect( "/v1/live/call_not_realtime", - headers={"Authorization": "Bearer live-key"}, + headers={"Authorization": "Bearer sk-clb-live-key"}, ): pass diff --git a/tests/integration/test_proxy_sticky_sessions.py b/tests/integration/test_proxy_sticky_sessions.py index a8e6a30aaa..539bff0005 100644 --- a/tests/integration/test_proxy_sticky_sessions.py +++ b/tests/integration/test_proxy_sticky_sessions.py @@ -23,6 +23,7 @@ realtime_call_affinity_key, ) from app.modules.proxy.affinity import _codex_session_selection_key +from app.modules.proxy.realtime_auth import RealtimeCallerScope from app.modules.usage.repository import UsageRepository pytestmark = pytest.mark.integration @@ -1737,6 +1738,28 @@ def test_realtime_call_affinity_key_is_scoped_and_opaque() -> None: assert "api-key-a" not in key_a +def test_realtime_call_affinity_key_preserves_registered_key_digest_bytes() -> None: + api_key = cast(ApiKeyData, SimpleNamespace(id="api-key-a")) + scope = RealtimeCallerScope.for_api_key(api_key) + + assert realtime_call_affinity_key("rtc_secret", scope) == ( + "\ncodex_live_call:9852ed42d5f5680e4f4cbf32d661da59e29eccca89b87c8a65cac39385faa72e" + ) + + +def test_realtime_call_affinity_key_scopes_oauth_by_principal() -> None: + scope_a = RealtimeCallerScope.for_oauth( + principal_id="caller-a", + allowed_account_ids={"upstream-a"}, + ) + scope_b = RealtimeCallerScope.for_oauth( + principal_id="caller-b", + allowed_account_ids={"upstream-a"}, + ) + + assert realtime_call_affinity_key("rtc_secret", scope_a) != realtime_call_affinity_key("rtc_secret", scope_b) + + @pytest.mark.asyncio async def test_realtime_call_owner_binding_is_immutable_and_persists_only_digest(async_client): from app.dependencies import get_proxy_service_for_app diff --git a/tests/unit/test_auth_dependencies_upstream_proxy.py b/tests/unit/test_auth_dependencies_upstream_proxy.py index 08ae665e05..a6d01d3c15 100644 --- a/tests/unit/test_auth_dependencies_upstream_proxy.py +++ b/tests/unit/test_auth_dependencies_upstream_proxy.py @@ -1,36 +1,26 @@ from __future__ import annotations -from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, cast import pytest from app.core.auth import dependencies as auth_dependencies -from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute, UpstreamProxyRouteError +from app.core.auth.codex_oauth_identity import VerifiedCodexOAuthIdentity +from app.core.exceptions import ProxyAuthError +from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute from app.core.usage.models import UsagePayload -from app.db.models import Account, AccountStatus +from app.modules.api_keys.service import ApiKeyData pytestmark = pytest.mark.unit -def _account() -> Account: - return Account( - id="acc_1", - chatgpt_account_id="chatgpt_1", - email="acc@example.com", - access_token_encrypted=b"access", - refresh_token_encrypted=b"refresh", - id_token_encrypted=b"id", - status=AccountStatus.ACTIVE, - ) - - @pytest.mark.asyncio -async def test_validate_codex_usage_identity_passes_resolved_route(monkeypatch: pytest.MonkeyPatch) -> None: - account = _account() +async def test_validate_codex_usage_identity_projects_verified_identity_to_request_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: request = SimpleNamespace( - headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, + headers={"Authorization": "Bearer oauth-token", "chatgpt-account-id": "workspace_account"}, state=SimpleNamespace(), ) route = ResolvedUpstreamRoute( @@ -38,260 +28,64 @@ async def test_validate_codex_usage_identity_passes_resolved_route(monkeypatch: pool_id="pool_1", endpoint=ResolvedProxyEndpoint("ep_1", "http", "proxy.test", 8080), ) - calls: dict[str, Any] = {} - - class Repo: - def __init__(self, session: object) -> None: - calls["repo_session"] = session - - async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: - calls["lookup"] = chatgpt_account_id - return account - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - calls["resolve_kwargs"] = kwargs - return route + usage_payload = UsagePayload(workspace_id="workspace_1", workspace_label="Team") + identity = VerifiedCodexOAuthIdentity( + principal_id="acc_2", + caller_account_id="acc_2", + chatgpt_account_id="workspace_account", + usage_payload=usage_payload, + route=route, + ) + calls: list[tuple[str | None, str | None]] = [] - async def fetch_usage(*args: object, **kwargs: object) -> None: - calls["fetch_kwargs"] = kwargs + async def resolve_identity( + authorization: str | None, + chatgpt_account_id: str | None, + ) -> VerifiedCodexOAuthIdentity: + calls.append((authorization, chatgpt_account_id)) + return identity - monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) - monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) - monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) + monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", resolve_identity) result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) assert result is None - assert calls["lookup"] == "chatgpt_1" - assert calls["resolve_kwargs"]["account_id"] == "acc_1" - assert calls["resolve_kwargs"]["operation"] == "usage_identity" - assert calls["fetch_kwargs"]["route"] is route - assert request.state.codex_usage_identity_access_token == "access" - assert request.state.codex_usage_identity_chatgpt_account_id == "chatgpt_1" - assert request.state.codex_usage_identity_account_id == "acc_1" + assert calls == [("Bearer oauth-token", "workspace_account")] + assert request.state.codex_usage_identity_access_token == "oauth-token" + assert request.state.codex_usage_identity_chatgpt_account_id == "workspace_account" + assert request.state.codex_usage_identity_account_id == "acc_2" assert request.state.codex_usage_identity_route is route + assert request.state.codex_usage_identity_payload is usage_payload @pytest.mark.asyncio -async def test_validate_codex_usage_identity_reresolves_route_for_workspace_account( +async def test_validate_codex_usage_identity_keeps_proxy_key_on_key_path( monkeypatch: pytest.MonkeyPatch, ) -> None: - account = _account() - workspace_account = _account() - workspace_account.id = auth_dependencies.generate_unique_account_id( - account.chatgpt_account_id, - account.email, - "ws_1", - "Team", - ) request = SimpleNamespace( - headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, + headers={"Authorization": "Bearer sk-clb-test", "chatgpt-account-id": "ignored"}, state=SimpleNamespace(), ) - owner_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="owner_pool", - endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), - ) - workspace_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="workspace_pool", - endpoint=ResolvedProxyEndpoint("workspace_ep", "http", "workspace-proxy.test", 8080), - ) - resolved_account_ids: list[str] = [] - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: - return account if chatgpt_account_id == "chatgpt_1" else None - - async def get_by_id(self, account_id: str) -> Account | None: - return workspace_account if account_id == workspace_account.id else None + expected = cast(ApiKeyData, SimpleNamespace(id="key_1")) - @asynccontextmanager - async def session_context(): - yield object() + async def validate_key(token: str) -> ApiKeyData: + assert token == "sk-clb-test" + return expected - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - account_id = cast(str, kwargs["account_id"]) - resolved_account_ids.append(account_id) - return workspace_route if account_id == workspace_account.id else owner_route + async def unexpected_oauth(*args: object) -> VerifiedCodexOAuthIdentity: + raise AssertionError("OAuth resolver must not receive a Proxy API Key") - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - assert kwargs["route"] is owner_route - return UsagePayload(workspace_id="ws_1", workspace_label="Team") - - monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) - monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) - monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) + monkeypatch.setattr(auth_dependencies, "_validate_api_key_token", validate_key) + monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", unexpected_oauth) result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) - assert result is None - assert resolved_account_ids == ["acc_1", workspace_account.id] - assert request.state.codex_usage_identity_account_id == workspace_account.id - assert request.state.codex_usage_identity_route is workspace_route + assert result is expected -@pytest.mark.parametrize("status", [AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED]) @pytest.mark.asyncio -async def test_validate_codex_usage_identity_reresolves_route_for_limited_workspace_account( - monkeypatch: pytest.MonkeyPatch, - status: AccountStatus, -) -> None: - account = _account() - workspace_account = _account() - workspace_account.id = auth_dependencies.generate_unique_account_id( - account.chatgpt_account_id, - account.email, - "ws_1", - "Team", - ) - workspace_account.status = status - request = SimpleNamespace( - headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, - state=SimpleNamespace(), - ) - owner_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="owner_pool", - endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), - ) - workspace_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="workspace_pool", - endpoint=ResolvedProxyEndpoint("workspace_ep", "http", "workspace-proxy.test", 8080), - ) - resolved_account_ids: list[str] = [] - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: - return account if chatgpt_account_id == "chatgpt_1" else None - - async def get_by_id(self, account_id: str) -> Account | None: - return workspace_account if account_id == workspace_account.id else None - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - account_id = cast(str, kwargs["account_id"]) - resolved_account_ids.append(account_id) - return workspace_route if account_id == workspace_account.id else owner_route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - assert kwargs["route"] is owner_route - return UsagePayload(workspace_id="ws_1", workspace_label="Team") +async def test_validate_codex_usage_identity_rejects_missing_bearer() -> None: + request = SimpleNamespace(headers={"chatgpt-account-id": "workspace_account"}, state=SimpleNamespace()) - monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) - monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) - monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) - - result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) - - assert result is None - assert resolved_account_ids == ["acc_1", workspace_account.id] - assert request.state.codex_usage_identity_account_id == workspace_account.id - assert request.state.codex_usage_identity_route is workspace_route - - -@pytest.mark.asyncio -async def test_validate_codex_usage_identity_rejects_inactive_workspace_account( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account = _account() - workspace_account = _account() - workspace_account.id = auth_dependencies.generate_unique_account_id( - account.chatgpt_account_id, - account.email, - "ws_1", - "Team", - ) - workspace_account.status = AccountStatus.PAUSED - request = SimpleNamespace( - headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, - state=SimpleNamespace(), - ) - owner_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="owner_pool", - endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), - ) - resolved_account_ids: list[str] = [] - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: - return account if chatgpt_account_id == "chatgpt_1" else None - - async def get_by_id(self, account_id: str) -> Account | None: - return workspace_account if account_id == workspace_account.id else None - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - resolved_account_ids.append(cast(str, kwargs["account_id"])) - return owner_route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - assert kwargs["route"] is owner_route - return UsagePayload(workspace_id="ws_1", workspace_label="Team") - - monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) - monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) - monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) - - with pytest.raises(auth_dependencies.ProxyAuthError): + with pytest.raises(ProxyAuthError, match="Missing ChatGPT token"): await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) - - assert resolved_account_ids == ["acc_1"] - assert not hasattr(request.state, "codex_usage_identity_account_id") - assert not hasattr(request.state, "codex_usage_identity_route") - - -@pytest.mark.asyncio -async def test_validate_codex_usage_identity_fails_closed_when_route_unavailable( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account = _account() - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: - return account - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - raise UpstreamProxyRouteError("default_pool_unconfigured", account_id="acc_1") - - monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) - monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) - monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) - - with pytest.raises(auth_dependencies.ProxyUpstreamError): - await auth_dependencies.validate_codex_usage_identity( - cast(Any, SimpleNamespace(headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"})) - ) diff --git a/tests/unit/test_codex_oauth_identity.py b/tests/unit/test_codex_oauth_identity.py new file mode 100644 index 0000000000..d586b62027 --- /dev/null +++ b/tests/unit/test_codex_oauth_identity.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from app.core.auth import codex_oauth_identity +from app.core.clients.usage import UsageFetchError +from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError +from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute, UpstreamProxyRouteError +from app.core.usage.models import UsagePayload +from app.db.models import Account, AccountStatus + +pytestmark = pytest.mark.unit + + +def _jwt(payload: dict[str, Any]) -> str: + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + return f"header.{encoded}.signature" + + +def _account( + account_id: str, + *, + chatgpt_user_id: str | None, + id_token: bytes, +) -> Account: + return Account( + id=account_id, + chatgpt_account_id="workspace_account", + chatgpt_user_id=chatgpt_user_id, + email=f"{account_id}@example.com", + workspace_id="workspace_1", + workspace_label="Team", + plan_type="team", + access_token_encrypted=b"access", + refresh_token_encrypted=b"refresh", + id_token_encrypted=id_token, + status=AccountStatus.ACTIVE, + ) + + +def _install_single_account_fakes( + monkeypatch: pytest.MonkeyPatch, + fetch_usage: Any, +) -> tuple[Account, ResolvedUpstreamRoute]: + account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") + route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="pool", + endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), + ) + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + return [account] + + class Encryptor: + def decrypt(self, ciphertext: bytes) -> str: + return _jwt({"chatgpt_user_id": "user_a"}) + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + return route + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) + monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + return account, route + + +@pytest.fixture(autouse=True) +def _clear_identity_cache() -> None: + codex_oauth_identity.clear_codex_oauth_identity_cache() + + +@pytest.mark.asyncio +async def test_verified_oauth_principal_does_not_require_an_imported_account( + monkeypatch: pytest.MonkeyPatch, +) -> None: + access_token = _jwt({"chatgpt_user_id": "user-external"}) + + class EmptyRepo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, _account_id: str) -> list[Account]: + return [] + + @asynccontextmanager + async def session_context(): + yield object() + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + assert kwargs["access_token"] == access_token + assert kwargs["account_id"] == "workspace-external" + assert kwargs["route"] is None + assert kwargs["allow_direct_egress"] is True + return UsagePayload(workspace_id="workspace-external") + + async def resolve_route(*args: object, **kwargs: object) -> None: + assert kwargs["account_id"] is None + return None + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", EmptyRepo) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + + identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace-external", + ) + + assert identity.principal_id == "principal:user-external" + assert identity.caller_account_id is None + assert identity.route is None + + +@pytest.mark.asyncio +async def test_shared_workspace_resolves_the_matching_seat_alias(monkeypatch: pytest.MonkeyPatch) -> None: + account_a = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") + account_b = _account("acc_b", chatgpt_user_id="user_b", id_token=b"id_b") + access_token = _jwt({"sub": "auth0|seat_b"}) + stored_tokens = { + b"id_a": _jwt({"sub": "auth0|seat_a"}), + b"id_b": _jwt({"sub": "auth0|seat_b"}), + } + caller_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="caller_pool", + endpoint=ResolvedProxyEndpoint("caller_ep", "http", "caller.test", 8080), + ) + route_account_ids: list[str] = [] + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + assert chatgpt_account_id == "workspace_account" + return [account_a, account_b] + + class Encryptor: + def decrypt(self, ciphertext: bytes) -> str: + return stored_tokens[ciphertext] + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + account_id = str(kwargs["account_id"]) + route_account_ids.append(account_id) + return caller_route + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + assert kwargs["access_token"] == access_token + assert kwargs["account_id"] == "workspace_account" + assert kwargs["route"] is caller_route + return UsagePayload(workspace_id="workspace_1", workspace_label="Team") + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) + monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + + identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + " workspace_account ", + ) + + assert identity.caller_account_id == "acc_b" + assert identity.principal_id == "acc_b" + assert identity.chatgpt_account_id == "workspace_account" + assert identity.usage_payload.workspace_id == "workspace_1" + assert identity.route is caller_route + assert route_account_ids == ["acc_b"] + + +@pytest.mark.asyncio +async def test_identity_route_failure_maps_to_credential_safe_upstream_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + access_token = _jwt({"chatgpt_user_id": "user_a"}) + + async def unused_fetch(*args: object, **kwargs: object) -> UsagePayload: + raise AssertionError("usage validation must remain unreachable") + + _install_single_account_fakes(monkeypatch, unused_fetch) + + async def fail_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + raise UpstreamProxyRouteError("default_pool_unconfigured", account_id="acc_a") + + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", fail_route) + + with pytest.raises(ProxyUpstreamError, match="Unable to resolve upstream proxy route") as exc_info: + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert access_token not in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_shared_workspace_identity_fails_closed_when_ambiguous( + monkeypatch: pytest.MonkeyPatch, +) -> None: + account_a = _account("acc_a", chatgpt_user_id=None, id_token=b"id_a") + account_b = _account("acc_b", chatgpt_user_id=None, id_token=b"id_b") + access_token = _jwt({}) + route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="pool", + endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), + ) + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + return [account_a, account_b] + + class Encryptor: + def decrypt(self, ciphertext: bytes) -> str: + return _jwt({}) + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + return route + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + return UsagePayload(workspace_id="workspace_1") + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) + monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + + with pytest.raises(ProxyAuthError, match="Unknown or ambiguous ChatGPT identity"): + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + +@pytest.mark.asyncio +async def test_concurrent_identity_resolution_coalesces_upstream_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") + access_token = _jwt({"chatgpt_user_id": "user_a"}) + route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="pool", + endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), + ) + fetch_count = 0 + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + return [account] + + class Encryptor: + def decrypt(self, ciphertext: bytes) -> str: + return _jwt({"chatgpt_user_id": "user_a"}) + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + return route + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + return UsagePayload(workspace_id="workspace_1") + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) + monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + + first, second = await asyncio.gather( + codex_oauth_identity.resolve_verified_codex_oauth_identity(f"Bearer {access_token}", "workspace_account"), + codex_oauth_identity.resolve_verified_codex_oauth_identity(f"Bearer {access_token}", "workspace_account"), + ) + + assert first == second + assert fetch_count == 1 + + +@pytest.mark.asyncio +async def test_positive_cache_ttl_tracks_token_expiry_and_rotation_uses_a_new_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fetch_count = 0 + wall_time = 1_000.0 + monotonic_time = 200.0 + short_token = _jwt({"chatgpt_user_id": "user_a", "exp": 1_010}) + rotated_token = _jwt({"chatgpt_user_id": "user_a", "exp": 1_600}) + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, fetch_usage) + monkeypatch.setattr(codex_oauth_identity.time, "time", lambda: wall_time) + monkeypatch.setattr(codex_oauth_identity.time, "monotonic", lambda: monotonic_time) + + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {short_token}", + "workspace_account", + ) + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {rotated_token}", + "workspace_account", + ) + + short_key = codex_oauth_identity._credential_digest(short_token, "workspace_account") + rotated_key = codex_oauth_identity._credential_digest(rotated_token, "workspace_account") + assert short_key != rotated_key + assert codex_oauth_identity._identity_cache[short_key].expires_at == pytest.approx(210.0) + assert codex_oauth_identity._identity_cache[rotated_key].expires_at == pytest.approx(260.0) + assert fetch_count == 2 + + +@pytest.mark.asyncio +async def test_cancelled_waiter_does_not_cancel_shared_validation(monkeypatch: pytest.MonkeyPatch) -> None: + fetch_started = asyncio.Event() + release_fetch = asyncio.Event() + fetch_count = 0 + access_token = _jwt({"chatgpt_user_id": "user_a"}) + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + fetch_started.set() + await release_fetch.wait() + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, fetch_usage) + + cancelled_waiter = asyncio.create_task( + codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + ) + await fetch_started.wait() + surviving_waiter = asyncio.create_task( + codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + ) + await asyncio.sleep(0) + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + release_fetch.set() + + identity = await surviving_waiter + assert identity.caller_account_id == "acc_a" + assert fetch_count == 1 + + +@pytest.mark.parametrize( + ("status_code", "expected_error"), + [(429, ProxyRateLimitError), (503, ProxyUpstreamError)], +) +@pytest.mark.asyncio +async def test_transient_validation_failure_is_not_cached( + monkeypatch: pytest.MonkeyPatch, + status_code: int, + expected_error: type[Exception], +) -> None: + access_token = _jwt({"chatgpt_user_id": "user_a"}) + fetch_count = 0 + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + if fetch_count == 1: + raise UsageFetchError(status_code, f"transient {access_token}") + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, fetch_usage) + + with pytest.raises(expected_error) as exc_info: + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + assert access_token not in str(exc_info.value) + + identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + assert identity.caller_account_id == "acc_a" + assert fetch_count == 2 + + +@pytest.mark.asyncio +async def test_identity_cache_public_result_and_denial_do_not_retain_raw_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + access_token = _jwt({"chatgpt_user_id": "user_a"}) + + async def successful_fetch(*args: object, **kwargs: object) -> UsagePayload: + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, successful_fetch) + identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert not hasattr(identity, "access_token") + assert access_token not in repr(identity) + assert all(access_token not in key for key in codex_oauth_identity._identity_cache) + + codex_oauth_identity.clear_codex_oauth_identity_cache() + + async def denied_fetch(*args: object, **kwargs: object) -> UsagePayload: + raise UsageFetchError(401, f"rejected bearer {access_token}") + + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", denied_fetch) + with pytest.raises(ProxyAuthError) as exc_info: + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert access_token not in str(exc_info.value) + assert access_token not in repr(codex_oauth_identity._identity_cache) + + +@pytest.mark.asyncio +async def test_credential_denial_is_cached_without_repeating_upstream_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") + access_token = _jwt({"chatgpt_user_id": "user_a"}) + route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="pool", + endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), + ) + fetch_count = 0 + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: + return [account] + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + return route + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + raise UsageFetchError(401, "credential rejected") + + monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) + monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) + monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) + + for _ in range(2): + with pytest.raises(ProxyAuthError, match="Invalid ChatGPT token"): + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert fetch_count == 1 diff --git a/tests/unit/test_db_migrate.py b/tests/unit/test_db_migrate.py index 03a5fc23ed..3aacff2b34 100644 --- a/tests/unit/test_db_migrate.py +++ b/tests/unit/test_db_migrate.py @@ -2030,7 +2030,9 @@ def test_capability_lineage_migration_is_additive_reversible_and_single_head(tmp run_upgrade(url, parent_revision, bootstrap_legacy=False) config = _build_alembic_config(url) script_directory = ScriptDirectory.from_config(config) - assert script_directory.get_heads() == [target_revision] + assert script_directory.get_heads() == ["20260803_000000_add_oauth_live_policies"] + ancestry = {script.revision for script in script_directory.walk_revisions()} + assert target_revision in ancestry engine = create_engine(to_sync_database_url(url)) try: diff --git a/tests/unit/test_proxy_utils.py b/tests/unit/test_proxy_utils.py index f5d77f1a88..c7f8e68460 100644 --- a/tests/unit/test_proxy_utils.py +++ b/tests/unit/test_proxy_utils.py @@ -5262,6 +5262,24 @@ async def test_select_codex_control_account_without_budget_honors_traffic_class( assert select_account.await_args.kwargs["traffic_class"] == proxy_service.TRAFFIC_CLASS_OPPORTUNISTIC +@pytest.mark.asyncio +async def test_select_codex_control_account_without_budget_honors_oauth_policy_scope(monkeypatch): + service = proxy_service.ProxyService(_repo_factory(_RequestLogsRecorder())) + selected_account = _make_account("acc_oauth_allowed") + select_account = AsyncMock(return_value=AccountSelection(account=selected_account, error_message=None)) + monkeypatch.setattr(service._load_balancer, "select_account", select_account) + + result = await service._select_codex_control_account_without_budget( + affinity=proxy_service._AffinityPolicy(key=None, kind=None), + api_key=None, + allowed_account_ids=frozenset({selected_account.id}), + ) + + assert result is not None + assert select_account.await_args is not None + assert select_account.await_args.kwargs["account_ids"] == {selected_account.id} + + @pytest.fixture(autouse=True) def _install_default_proxy_runtime_settings(monkeypatch: pytest.MonkeyPatch) -> None: settings = _make_proxy_settings() @@ -5485,6 +5503,64 @@ async def codex_control_request(*_args: object, **_kwargs: object) -> proxy_modu assert request_logs.calls[0]["conversation_id"] == "conv-control" +@pytest.mark.asyncio +async def test_realtime_codex_control_policy_scope_reaches_initial_and_post_401_failover( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_logs = _RequestLogsRecorder() + service = proxy_service.ProxyService(_repo_factory(request_logs)) + account_a = _make_account("acc_live_scope_a") + account_b = _make_account("acc_live_scope_b") + allowed_account_ids = frozenset({account_a.id, account_b.id}) + selection_scopes: list[frozenset[str] | None] = [] + + async def initial_selection(_deadline: float, **kwargs: object) -> AccountSelection: + selection_scopes.append(cast(frozenset[str] | None, kwargs.get("allowed_account_ids"))) + return AccountSelection(account=account_a, error_message=None) + + async def failover_selection(_deadline: float, **kwargs: object) -> AccountSelection: + selection_scopes.append(cast(frozenset[str] | None, kwargs.get("allowed_account_ids"))) + assert kwargs["exclude_account_ids"] == {account_a.id} + return AccountSelection(account=account_b, error_message=None) + + async def preserve_selected_account(account: Account, **_kwargs: object) -> Account: + return account + + upstream_accounts: list[str | None] = [] + + async def fake_codex_control_request(*_args: object, account_id: str | None, **_kwargs: object): + upstream_accounts.append(account_id) + if account_id == account_a.chatgpt_account_id: + raise proxy_module.ProxyResponseError(401, openai_error("invalid_api_key", "expired")) + return proxy_module.CodexControlResponse(status_code=201, body=b"answer", headers={}) + + monkeypatch.setattr(service, "_select_account_with_budget_compatible", initial_selection) + monkeypatch.setattr(service, "_select_account_with_budget", failover_selection) + monkeypatch.setattr(service, "_ensure_previsible_unary_fresh_with_failover", preserve_selected_account) + monkeypatch.setattr(service, "_ensure_fresh_with_budget_or_auth_error", preserve_selected_account) + monkeypatch.setattr(service, "_handle_proxy_error", AsyncMock()) + monkeypatch.setattr(service._load_balancer, "record_success", AsyncMock()) + monkeypatch.setattr(proxy_service, "core_codex_control_request", fake_codex_control_request) + + response = await service.codex_control_request( + "realtime/calls", + method="POST", + payload=b"offer", + query_params={}, + headers={}, + allowed_account_ids=allowed_account_ids, + privacy_policy=proxy_module.CodexControlRequestPrivacyPolicy.PRIVATE_REALTIME, + ) + + assert response.status_code == 201 + assert upstream_accounts == [ + account_a.chatgpt_account_id, + account_a.chatgpt_account_id, + account_b.chatgpt_account_id, + ] + assert selection_scopes == [allowed_account_ids, allowed_account_ids] + + class _JsonCompactResponse: def __init__(self, payload: dict[str, object]) -> None: self.status = 200 diff --git a/tests/unit/test_realtime_auth.py b/tests/unit/test_realtime_auth.py new file mode 100644 index 0000000000..febd386806 --- /dev/null +++ b/tests/unit/test_realtime_auth.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast + +import pytest + +import app.core.auth.codex_oauth_identity as oauth_identity_module +import app.modules.proxy.realtime_auth as realtime_auth_module +from app.core.exceptions import ProxyAuthError +from app.modules.api_keys.service import ApiKeyData + + +@pytest.mark.asyncio +async def test_sk_clb_bearer_uses_strict_key_path_without_oauth_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + oauth_calls = 0 + + async def reject_key(_authorization: str | None) -> ApiKeyData: + raise ProxyAuthError("invalid registered key") + + async def unexpected_oauth(*_args: object) -> object: + nonlocal oauth_calls + oauth_calls += 1 + raise AssertionError("OAuth fallback must remain unreachable") + + monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", unexpected_oauth) + + with pytest.raises(ProxyAuthError, match="invalid registered key"): + await realtime_auth_module.resolve_realtime_caller_scope( + "Bearer sk-clb-invalid", + "workspace-id", + api_key_validator=reject_key, + ) + + assert oauth_calls == 0 + + +@pytest.mark.asyncio +async def test_registered_key_scope_preserves_key_assignments_in_service_layer() -> None: + api_key = cast( + ApiKeyData, + SimpleNamespace( + id="key-id", + account_assignment_scope_enabled=True, + assigned_account_ids=frozenset({"account-a"}), + ), + ) + + async def validate_key(_authorization: str | None) -> ApiKeyData: + return api_key + + scope = await realtime_auth_module.resolve_realtime_caller_scope( + "Bearer sk-clb-valid", + None, + api_key_validator=validate_key, + ) + + assert scope.kind == "api_key" + assert scope.affinity_scope_material == "key-id" + assert scope.api_key is api_key + assert scope.allowed_account_ids is None + + +@pytest.mark.asyncio +async def test_oauth_scope_uses_verified_principal_and_fresh_global_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + identity = SimpleNamespace(principal_id="principal-1") + + async def resolve_identity(_authorization: str | None, _account_id: str | None) -> object: + return identity + + async def load_policy() -> frozenset[str]: + return frozenset({"allowed-a", "allowed-b"}) + + monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", resolve_identity) + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) + + scope = await realtime_auth_module.resolve_realtime_caller_scope( + "Bearer oauth-token", + "workspace-id", + ) + + assert scope.kind == "oauth" + assert scope.affinity_scope_material == "oauth:principal-1" + assert scope.api_key is None + assert scope.allowed_account_ids == frozenset({"allowed-a", "allowed-b"}) + + +@pytest.mark.asyncio +async def test_oauth_scope_fails_closed_when_policy_has_no_active_accounts( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def resolve_identity(_authorization: str | None, _account_id: str | None) -> object: + return SimpleNamespace(principal_id="principal-1") + + async def load_policy() -> frozenset[str]: + return frozenset() + + monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", resolve_identity) + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) + + with pytest.raises(realtime_auth_module.OAuthLiveNotEnabledError) as raised: + await realtime_auth_module.resolve_realtime_caller_scope( + "Bearer oauth-token", + "workspace-id", + ) + + assert raised.value.status_code == 403 + assert raised.value.code == "oauth_live_not_enabled" diff --git a/tests/unit/test_realtime_live.py b/tests/unit/test_realtime_live.py index c06aae4a36..e04ffd2dc4 100644 --- a/tests/unit/test_realtime_live.py +++ b/tests/unit/test_realtime_live.py @@ -25,6 +25,7 @@ realtime_call_id_from_location, ) from app.modules.proxy.load_balancer import AccountLease, AccountSelection +from app.modules.proxy.realtime_auth import RealtimeCallerScope def _unscoped_api_key(*, key_id: str = "api-key-a") -> ApiKeyData: @@ -200,6 +201,7 @@ def __init__( self._load_balancer = _FakeLoadBalancer() self.selection_calls: list[dict[str, object]] = [] self.decrypt_calls: list[str] = [] + self.request_log_calls: list[dict[str, object]] = [] self._encryptor = SimpleNamespace(decrypt=self._decrypt) self._live_websocket_connector = live_websocket_connector @@ -210,9 +212,9 @@ def _decrypt(self, value: str) -> str: self.decrypt_calls.append(value) return f"decrypted:{value}" - async def _resolve_realtime_call_owner(self, call_id: str, *, api_key): + async def _resolve_realtime_call_owner(self, call_id: str, *, caller_scope=None, api_key=None): assert call_id == "rtc_example" - assert api_key is not None + assert caller_scope is not None or api_key is not None return self.owner_account_id async def _select_account_with_budget_compatible(self, _deadline: float, **_kwargs): @@ -225,7 +227,7 @@ async def _resolve_upstream_route_for_account(self, account, *, operation: str): return None async def _write_request_log(self, **_kwargs) -> None: - return None + self.request_log_calls.append(_kwargs) @pytest.mark.parametrize( @@ -877,6 +879,67 @@ async def fake_connect_live_websocket(*_args, subprotocols, **_kwargs): assert service._load_balancer.released == [lease] +@pytest.mark.asyncio +async def test_oauth_live_sideband_carries_policy_scope_and_logs_null_api_key() -> None: + lease = cast(AccountLease, object()) + account = SimpleNamespace( + id="account-a", + status=AccountStatus.ACTIVE, + access_token_encrypted="encrypted-token", + chatgpt_account_id="chatgpt-account-a", + codex_installation_id="installation-a", + ) + downstream = _FakeDownstreamWebSocket() + upstream = _FakeUpstreamWebSocket() + + async def fake_connect_live_websocket(*_args, **_kwargs): + return upstream + + service = _ProxyService(account, lease, live_websocket_connector=fake_connect_live_websocket) + caller_scope = RealtimeCallerScope.for_oauth( + principal_id="caller-account", + allowed_account_ids={"account-a"}, + ) + + await service.proxy_realtime_live_websocket( + cast(Any, downstream), + "rtc_example", + {}, + protocol=proxy_websocket_module.RealtimeWebSocketProtocol.LIVE_V3, + caller_scope=caller_scope, + ) + + assert service.selection_calls[0]["api_key"] is None + assert service.selection_calls[0]["allowed_account_ids"] == frozenset({"account-a"}) + assert service.selection_calls[0]["preferred_account_id"] == "account-a" + assert service.selection_calls[0]["preferred_account_is_continuity_owner"] is True + assert service.selection_calls[0]["fallback_on_preferred_account_unavailable"] is False + assert service.request_log_calls[0]["api_key"] is None + + +@pytest.mark.asyncio +async def test_oauth_live_sideband_rejects_owner_removed_from_current_policy_before_selection() -> None: + service = _ProxyService(SimpleNamespace(id="account-a"), None) + caller_scope = RealtimeCallerScope.for_oauth( + principal_id="caller-account", + allowed_account_ids={"account-b"}, + ) + + with pytest.raises(ProxyResponseError) as raised: + await service.proxy_realtime_live_websocket( + cast(Any, _FakeDownstreamWebSocket()), + "rtc_example", + {}, + protocol=proxy_websocket_module.RealtimeWebSocketProtocol.LIVE_V3, + caller_scope=caller_scope, + ) + + assert raised.value.status_code == 404 + assert raised.value.payload["error"]["code"] == "realtime_call_not_found" + assert service.selection_calls == [] + assert service.decrypt_calls == [] + + @pytest.mark.asyncio async def test_live_sideband_rejects_an_upstream_subprotocol_the_client_did_not_offer() -> None: lease = cast(AccountLease, object()) From 26d657492bb72fff61c251b9b564a252a8a4d973 Mon Sep 17 00:00:00 2001 From: crowscc Date: Mon, 3 Aug 2026 22:50:30 +0800 Subject: [PATCH 2/9] fix(proxy): reject conflicting Live call IDs before auth --- app/modules/proxy/api.py | 27 +++++++++++-------- tests/integration/test_proxy_realtime_live.py | 18 +++++++++++-- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/app/modules/proxy/api.py b/app/modules/proxy/api.py index eb71178946..7ad0b72749 100644 --- a/app/modules/proxy/api.py +++ b/app/modules/proxy/api.py @@ -1302,20 +1302,28 @@ async def _proxy_realtime_live_websocket_route( redacted_path: str, ) -> None: _redact_realtime_live_websocket_scope(websocket, path=redacted_path) - caller_scope, denial = await _validate_realtime_caller_websocket_request(websocket) + denial = await _websocket_firewall_denial_response(websocket) if denial is not None: await websocket.send_denial_response(denial) return - assert caller_scope is not None - try: - if protocol is RealtimeWebSocketProtocol.LIVE_V3 and any(key == "call_id" for key, _value in query_params): - raise ProxyResponseError( - 400, - openai_error( + if protocol is RealtimeWebSocketProtocol.LIVE_V3 and any(key == "call_id" for key, _value in query_params): + await websocket.send_denial_response( + JSONResponse( + status_code=400, + content=openai_error( "invalid_realtime_call_id", "Path-based realtime sidebands must not include a call_id query parameter", ), ) + ) + return + + caller_scope, denial = await _resolve_realtime_caller_websocket_request(websocket) + if denial is not None: + await websocket.send_denial_response(denial) + return + assert caller_scope is not None + try: await context.service.proxy_realtime_live_websocket( websocket, call_id, @@ -6278,12 +6286,9 @@ async def _validate_proxy_websocket_request( return api_key, None -async def _validate_realtime_caller_websocket_request( +async def _resolve_realtime_caller_websocket_request( websocket: WebSocket, ) -> tuple[RealtimeCallerScope | None, JSONResponse | None]: - denial = await _websocket_firewall_denial_response(websocket) - if denial is not None: - return None, denial try: caller_scope = await resolve_realtime_caller_scope( websocket.headers.get("authorization"), diff --git a/tests/integration/test_proxy_realtime_live.py b/tests/integration/test_proxy_realtime_live.py index 1623d03aa2..15eca9b3da 100644 --- a/tests/integration/test_proxy_realtime_live.py +++ b/tests/integration/test_proxy_realtime_live.py @@ -494,27 +494,41 @@ def observe_rejection(client: TestClient, path: str) -> tuple[str, int | None, b ], ids=["current-app", "v3"], ) -def test_path_realtime_sideband_rejects_query_call_id_before_service( +def test_path_realtime_sideband_rejects_query_call_id_before_caller_resolution_and_service( app_instance, monkeypatch, path: str, ): + caller_resolution_called = False service_called = False + async def fail_resolve_caller(*_args, **_kwargs): + nonlocal caller_resolution_called + caller_resolution_called = True + raise AssertionError("path call_id conflict must fail before caller resolution") + async def fail_proxy_live(*_args, **_kwargs): nonlocal service_called service_called = True raise AssertionError("path call_id conflict must fail before the sideband service") + monkeypatch.setattr(proxy_api_module, "resolve_realtime_caller_scope", fail_resolve_caller) monkeypatch.setattr(proxy_module.ProxyService, "proxy_realtime_live_websocket", fail_proxy_live) with TestClient(app_instance) as client: with pytest.raises(WebSocketDenialResponse) as raised: - with client.websocket_connect(path, headers={"Authorization": "Bearer sk-clb-live-key"}): + with client.websocket_connect( + path, + headers={ + "Authorization": "Bearer oauth-live-token", + "chatgpt-account-id": "workspace-live", + }, + ): pass assert raised.value.status_code == 400 assert json.loads(raised.value.content)["error"]["code"] == "invalid_realtime_call_id" + assert caller_resolution_called is False assert service_called is False From ddddd6b463ed39192690b117beeebc27652caf0c Mon Sep 17 00:00:00 2001 From: crowscc Date: Mon, 3 Aug 2026 23:03:57 +0800 Subject: [PATCH 3/9] fix(auth): keep OAuth Live affinity stable across account changes --- app/core/auth/codex_oauth_identity.py | 10 ++- .../add-oauth-live-voice-auth/design.md | 2 +- .../specs/account-identity/spec.md | 12 ++-- openspec/specs/account-identity/spec.md | 12 ++-- tests/unit/test_codex_oauth_identity.py | 67 +++++++++++++++---- 5 files changed, 74 insertions(+), 29 deletions(-) diff --git a/app/core/auth/codex_oauth_identity.py b/app/core/auth/codex_oauth_identity.py index c6d99403a9..03032024c6 100644 --- a/app/core/auth/codex_oauth_identity.py +++ b/app/core/auth/codex_oauth_identity.py @@ -167,12 +167,16 @@ async def _validate_identity_uncached( if caller is None and stable_principal is None: raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") caller_account_id = caller.id if caller is not None else None - if caller_account_id is None: + if stable_principal is not None: principal_id = f"principal:{stable_principal}" + elif caller_account_id is not None: + principal_id = caller_account_id + else: + raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") + + if caller_account_id is None: route = validation_route else: - # Imported callers use their Account id as stable affinity material. - principal_id = caller_account_id route = ( validation_route if caller_account_id == validation_account_id diff --git a/openspec/changes/add-oauth-live-voice-auth/design.md b/openspec/changes/add-oauth-live-voice-auth/design.md index e65724bae6..8f48c2c484 100644 --- a/openspec/changes/add-oauth-live-voice-auth/design.md +++ b/openspec/changes/add-oauth-live-voice-auth/design.md @@ -19,7 +19,7 @@ Live Voice has two authenticated legs: HTTP call creation and a control sideband ### Separate caller identity from serving accounts -The OAuth resolver validates the supplied credential pair against the upstream usage endpoint. A matching imported seat retains its internal Account id as affinity material. An external caller receives `principal:{stable_seat_claim}` from verified `chatgpt_user_id` or `sub`. Its usage check follows the configured default upstream proxy route when routing is enabled. +The OAuth resolver validates the supplied credential pair against the upstream usage endpoint. Every caller with a stable verified `chatgpt_user_id` or `sub` receives `principal:{stable_seat_claim}` whether or not an imported Account matches. A matching imported Account remains optional `caller_account_id` metadata for usage and route integration. Credentials without a stable claim fall back to one unambiguous imported Account id. External callers follow the configured default upstream proxy route when routing is enabled. The typed result contains `principal_id`, optional `caller_account_id` for usage integration, normalized ChatGPT account id, verified usage payload, and route. Raw credentials remain absent from logs and persistence. diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md index db08357ae0..311791043f 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md @@ -2,7 +2,7 @@ ### Requirement: Verified OAuth Live callers resolve to a stable principal -The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. A matching imported seat MAY retain its internal Account id for affinity compatibility; a verified caller without an imported Account SHALL remain authorized through its independent principal. A credential without a stable claim SHALL require one unambiguous imported seat. +The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. That principal SHALL remain unchanged when a matching imported Account is added, removed, paused, or otherwise becomes ineligible. A matching imported Account MAY remain attached separately as caller integration metadata for usage and route selection. A credential without a stable claim SHALL require one unambiguous imported Account and MAY use that Account id as its fallback principal. #### Scenario: External OAuth caller is accepted @@ -11,12 +11,12 @@ The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` aga - **THEN** upstream credential validation succeeds through the configured default route - **AND** the caller receives an independent stable principal -#### Scenario: Imported caller uses its Account affinity +#### Scenario: Imported Account lifecycle preserves caller affinity -- **GIVEN** a verified caller matches one imported seat -- **WHEN** the resolver derives its principal -- **THEN** the existing internal Account id remains the affinity material -- **AND** repeated Live requests retain a stable caller scope +- **GIVEN** verified OAuth credentials carry a stable seat claim +- **WHEN** a matching imported Account is added, removed, paused, or otherwise becomes ineligible +- **THEN** the principal remains derived from the same stable seat claim +- **AND** caller Account metadata may change independently without changing the Live ownership scope #### Scenario: Identity remains ambiguous diff --git a/openspec/specs/account-identity/spec.md b/openspec/specs/account-identity/spec.md index 1418c3d10e..f22c26f7ba 100644 --- a/openspec/specs/account-identity/spec.md +++ b/openspec/specs/account-identity/spec.md @@ -24,7 +24,7 @@ Dashboard account summaries MUST expose and render the upstream ChatGPT account ### Requirement: Verified OAuth Live callers resolve to a stable principal -The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. A matching imported seat MAY retain its internal Account id for affinity compatibility; a verified caller without an imported Account SHALL remain authorized through its independent principal. A credential without a stable claim SHALL require one unambiguous imported seat. +The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. That principal SHALL remain unchanged when a matching imported Account is added, removed, paused, or otherwise becomes ineligible. A matching imported Account MAY remain attached separately as caller integration metadata for usage and route selection. A credential without a stable claim SHALL require one unambiguous imported Account and MAY use that Account id as its fallback principal. #### Scenario: External OAuth caller is accepted @@ -33,12 +33,12 @@ The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` aga - **THEN** upstream credential validation succeeds through the configured default route - **AND** the caller receives an independent stable principal -#### Scenario: Imported caller uses its Account affinity +#### Scenario: Imported Account lifecycle preserves caller affinity -- **GIVEN** a verified caller matches one imported seat -- **WHEN** the resolver derives its principal -- **THEN** the existing internal Account id remains the affinity material -- **AND** repeated Live requests retain a stable caller scope +- **GIVEN** verified OAuth credentials carry a stable seat claim +- **WHEN** a matching imported Account is added, removed, paused, or otherwise becomes ineligible +- **THEN** the principal remains derived from the same stable seat claim +- **AND** caller Account metadata may change independently without changing the Live ownership scope #### Scenario: Identity remains ambiguous diff --git a/tests/unit/test_codex_oauth_identity.py b/tests/unit/test_codex_oauth_identity.py index d586b62027..aa74c48712 100644 --- a/tests/unit/test_codex_oauth_identity.py +++ b/tests/unit/test_codex_oauth_identity.py @@ -130,7 +130,9 @@ async def resolve_route(*args: object, **kwargs: object) -> None: @pytest.mark.asyncio -async def test_shared_workspace_resolves_the_matching_seat_alias(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_imported_seat_lifecycle_preserves_stable_principal_and_updates_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: account_a = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") account_b = _account("acc_b", chatgpt_user_id="user_b", id_token=b"id_b") access_token = _jwt({"sub": "auth0|seat_b"}) @@ -143,7 +145,13 @@ async def test_shared_workspace_resolves_the_matching_seat_alias(monkeypatch: py pool_id="caller_pool", endpoint=ResolvedProxyEndpoint("caller_ep", "http", "caller.test", 8080), ) - route_account_ids: list[str] = [] + default_route = ResolvedUpstreamRoute( + mode="default", + pool_id="default_pool", + endpoint=ResolvedProxyEndpoint("default_ep", "http", "default.test", 8080), + ) + eligible_accounts = [account_a, account_b] + route_account_ids: list[str | None] = [] class Repo: def __init__(self, session: object) -> None: @@ -151,7 +159,7 @@ def __init__(self, session: object) -> None: async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: assert chatgpt_account_id == "workspace_account" - return [account_a, account_b] + return list(eligible_accounts) class Encryptor: def decrypt(self, ciphertext: bytes) -> str: @@ -162,14 +170,15 @@ async def session_context(): yield object() async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - account_id = str(kwargs["account_id"]) + account_id = kwargs["account_id"] + assert account_id is None or isinstance(account_id, str) route_account_ids.append(account_id) - return caller_route + return default_route if account_id is None else caller_route async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: assert kwargs["access_token"] == access_token assert kwargs["account_id"] == "workspace_account" - assert kwargs["route"] is caller_route + assert kwargs["route"] in (caller_route, default_route) return UsagePayload(workspace_id="workspace_1", workspace_label="Team") monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) @@ -178,17 +187,49 @@ async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + imported_identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( f"Bearer {access_token}", " workspace_account ", ) - assert identity.caller_account_id == "acc_b" - assert identity.principal_id == "acc_b" - assert identity.chatgpt_account_id == "workspace_account" - assert identity.usage_payload.workspace_id == "workspace_1" - assert identity.route is caller_route - assert route_account_ids == ["acc_b"] + assert imported_identity.caller_account_id == "acc_b" + assert imported_identity.principal_id == "principal:auth0|seat_b" + assert imported_identity.chatgpt_account_id == "workspace_account" + assert imported_identity.usage_payload.workspace_id == "workspace_1" + assert imported_identity.route is caller_route + + codex_oauth_identity.clear_codex_oauth_identity_cache() + eligible_accounts.clear() + external_identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert external_identity.caller_account_id is None + assert external_identity.principal_id == imported_identity.principal_id + assert external_identity.route is default_route + assert route_account_ids == ["acc_b", None] + + +@pytest.mark.asyncio +async def test_identity_without_stable_claim_uses_unique_imported_account_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + access_token = _jwt({}) + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + return UsagePayload(workspace_id="workspace_1", workspace_label="Team") + + account, route = _install_single_account_fakes(monkeypatch, fetch_usage) + + identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + assert identity.principal_id == account.id + assert identity.caller_account_id == account.id + assert identity.route is route @pytest.mark.asyncio From 5959e764eb6198538597cd25ea5ccf2575d83115 Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 08:04:09 +0800 Subject: [PATCH 4/9] fix(oauth-live): close authorization and policy gaps --- app/core/auth/codex_oauth_identity.py | 46 ++++++++-- app/core/auth/dependencies.py | 2 + .../components/account-multi-select.test.tsx | 22 +++-- .../components/account-multi-select.tsx | 15 +++- .../add-oauth-live-voice-auth/design.md | 6 +- .../specs/account-identity/spec.md | 27 +++++- .../specs/frontend-architecture/spec.md | 7 ++ openspec/specs/account-identity/spec.md | 27 +++++- openspec/specs/frontend-architecture/spec.md | 7 ++ tests/integration/test_codex_usage_api.py | 29 +++++++ .../test_auth_dependencies_upstream_proxy.py | 30 +++++++ tests/unit/test_codex_oauth_identity.py | 85 +++++++++++++++++++ 12 files changed, 283 insertions(+), 20 deletions(-) diff --git a/app/core/auth/codex_oauth_identity.py b/app/core/auth/codex_oauth_identity.py index 03032024c6..828bef52ec 100644 --- a/app/core/auth/codex_oauth_identity.py +++ b/app/core/auth/codex_oauth_identity.py @@ -27,6 +27,7 @@ _POSITIVE_CACHE_TTL_SECONDS = 60.0 _DENIAL_CACHE_TTL_SECONDS = 5.0 _CACHE_MAX_ENTRIES = 256 +_INFLIGHT_MAX_ENTRIES = 32 @dataclass(frozen=True, slots=True) @@ -49,8 +50,14 @@ class _CacheEntry: expires_at: float +@dataclass(slots=True) +class _InflightValidation: + task: asyncio.Task[VerifiedCodexOAuthIdentity] + waiters: int + + _identity_cache: OrderedDict[str, _CacheEntry] = OrderedDict() -_identity_inflight: dict[str, asyncio.Task[VerifiedCodexOAuthIdentity]] = {} +_identity_inflight: dict[str, _InflightValidation] = {} _identity_lock = asyncio.Lock() @@ -58,6 +65,8 @@ def clear_codex_oauth_identity_cache() -> None: """Clear process-local identity state (used by tests and lifecycle resets).""" _identity_cache.clear() + for validation in _identity_inflight.values(): + validation.task.cancel() _identity_inflight.clear() @@ -81,8 +90,10 @@ async def resolve_verified_codex_oauth_identity( raise ProxyAuthError(cached.message) return cached - task = _identity_inflight.get(cache_key) - if task is None: + validation = _identity_inflight.get(cache_key) + if validation is None: + if len(_identity_inflight) >= _INFLIGHT_MAX_ENTRIES: + raise ProxyRateLimitError("Too many concurrent ChatGPT credential validations") task = asyncio.create_task( _validate_and_cache_identity( cache_key=cache_key, @@ -90,10 +101,17 @@ async def resolve_verified_codex_oauth_identity( chatgpt_account_id=normalized_account_id, ) ) - _identity_inflight[cache_key] = task + validation = _InflightValidation(task=task, waiters=0) + _identity_inflight[cache_key] = validation task.add_done_callback(lambda completed, key=cache_key: _remove_inflight(key, completed)) + elif validation.waiters == 0: + raise ProxyRateLimitError("ChatGPT credential validation is still being cancelled") + validation.waiters += 1 - return await asyncio.shield(task) + try: + return await asyncio.shield(validation.task) + finally: + await _release_inflight_waiter(cache_key, validation.task) async def _validate_and_cache_identity( @@ -337,5 +355,21 @@ def _remove_inflight( cache_key: str, completed: asyncio.Task[VerifiedCodexOAuthIdentity], ) -> None: - if _identity_inflight.get(cache_key) is completed: + validation = _identity_inflight.get(cache_key) + if validation is not None and validation.task is completed: _identity_inflight.pop(cache_key, None) + if not completed.cancelled(): + completed.exception() + + +async def _release_inflight_waiter( + cache_key: str, + task: asyncio.Task[VerifiedCodexOAuthIdentity], +) -> None: + async with _identity_lock: + validation = _identity_inflight.get(cache_key) + if validation is None or validation.task is not task: + return + validation.waiters -= 1 + if validation.waiters == 0 and not task.done(): + task.cancel() diff --git a/app/core/auth/dependencies.py b/app/core/auth/dependencies.py index 69f6c7eee4..7ed45d8d2b 100644 --- a/app/core/auth/dependencies.py +++ b/app/core/auth/dependencies.py @@ -289,6 +289,8 @@ async def validate_codex_usage_identity(request: Request) -> ApiKeyData | None: request.headers.get("Authorization"), request.headers.get("chatgpt-account-id"), ) + if identity.caller_account_id is None: + raise ProxyAuthError("ChatGPT identity is not an eligible imported account") request.state.codex_usage_identity_access_token = token request.state.codex_usage_identity_chatgpt_account_id = identity.chatgpt_account_id request.state.codex_usage_identity_account_id = identity.caller_account_id diff --git a/frontend/src/features/api-keys/components/account-multi-select.test.tsx b/frontend/src/features/api-keys/components/account-multi-select.test.tsx index 0565c67ff8..aea8b14960 100644 --- a/frontend/src/features/api-keys/components/account-multi-select.test.tsx +++ b/frontend/src/features/api-keys/components/account-multi-select.test.tsx @@ -107,7 +107,7 @@ describe("AccountMultiSelect", () => { expect(await screen.findByRole("button", { name: "2 accounts selected" })).toBeInTheDocument(); }); - it("excludes hard-blocked accounts from new selections", async () => { + it("keeps selected hard-blocked accounts removable while hiding unselected ones", async () => { server.use( http.get("/api/accounts", () => HttpResponse.json({ @@ -141,17 +141,27 @@ describe("AccountMultiSelect", () => { ); const user = userEvent.setup(); + const onChange = vi.fn(); - renderWithProviders(); - - expect(await screen.findByText("reauth-picker@example.com")).toBeInTheDocument(); + renderWithProviders( + , + ); - await user.click(screen.getByRole("button", { name: "1 account selected" })); + await user.click(await screen.findByRole("button", { name: "1 account selected" })); expect(await screen.findByRole("menuitemcheckbox", { name: /active-picker@example\.com/i })).toBeInTheDocument(); - expect(screen.queryByRole("menuitemcheckbox", { name: /reauth-picker@example\.com/i })).not.toBeInTheDocument(); + const selectedUnavailable = screen.getByRole("menuitemcheckbox", { name: /reauth-picker@example\.com/i }); + expect(selectedUnavailable).toBeChecked(); expect(screen.queryByRole("menuitemcheckbox", { name: /paused-picker@example\.com/i })).not.toBeInTheDocument(); expect(screen.queryByRole("menuitemcheckbox", { name: /deactivated-picker@example\.com/i })).not.toBeInTheDocument(); + + await user.click(selectedUnavailable); + expect(onChange).toHaveBeenCalledWith([]); }); it("can include paused accounts while keeping other hard-blocked accounts hidden", async () => { diff --git a/frontend/src/features/api-keys/components/account-multi-select.tsx b/frontend/src/features/api-keys/components/account-multi-select.tsx index 147b07e06a..d0a9981def 100644 --- a/frontend/src/features/api-keys/components/account-multi-select.tsx +++ b/frontend/src/features/api-keys/components/account-multi-select.tsx @@ -161,20 +161,27 @@ export function AccountMultiSelect({ ), [accounts, allowPausedAccounts], ); + const selectedSet = useMemo(() => new Set(value), [value]); + const visibleAccounts = useMemo(() => { + const selectableIds = new Set(selectableAccounts.map((account) => account.accountId)); + const selectedUnavailableAccounts = accounts.filter( + (account) => selectedSet.has(account.accountId) && !selectableIds.has(account.accountId), + ); + return [...selectableAccounts, ...selectedUnavailableAccounts]; + }, [accounts, selectableAccounts, selectedSet]); const [search, setSearch] = useState(""); const filtered = useMemo(() => { - if (!search.trim()) return selectableAccounts; + if (!search.trim()) return visibleAccounts; const query = search.toLowerCase(); - return selectableAccounts.filter( + return visibleAccounts.filter( (account) => account.accountId.toLowerCase().includes(query) || account.email.toLowerCase().includes(query) || account.displayName.toLowerCase().includes(query), ); - }, [search, selectableAccounts]); + }, [search, visibleAccounts]); - const selectedSet = useMemo(() => new Set(value), [value]); const selectedAccounts = useMemo( () => value diff --git a/openspec/changes/add-oauth-live-voice-auth/design.md b/openspec/changes/add-oauth-live-voice-auth/design.md index 8f48c2c484..b60a9358fc 100644 --- a/openspec/changes/add-oauth-live-voice-auth/design.md +++ b/openspec/changes/add-oauth-live-voice-auth/design.md @@ -21,7 +21,9 @@ Live Voice has two authenticated legs: HTTP call creation and a control sideband The OAuth resolver validates the supplied credential pair against the upstream usage endpoint. Every caller with a stable verified `chatgpt_user_id` or `sub` receives `principal:{stable_seat_claim}` whether or not an imported Account matches. A matching imported Account remains optional `caller_account_id` metadata for usage and route integration. Credentials without a stable claim fall back to one unambiguous imported Account id. External callers follow the configured default upstream proxy route when routing is enabled. -The typed result contains `principal_id`, optional `caller_account_id` for usage integration, normalized ChatGPT account id, verified usage payload, and route. Raw credentials remain absent from logs and persistence. +The typed result contains `principal_id`, optional `caller_account_id` for usage integration, normalized ChatGPT account id, verified usage payload, and route. Raw credentials remain absent from logs and persistence. OAuth Live accepts independent verified principals through its global policy, while `/api/codex/usage` requires an eligible imported `caller_account_id` before exposing aggregate local pool usage. + +Identity validation admits at most 32 distinct in-flight credential pairs per process. Same-pair callers share one task. The final departing waiter cancels unfinished work, and cancelling work retains its admission slot until the task drains. ### Use one global policy @@ -35,7 +37,7 @@ Key callers keep `api_key.id` affinity, assignments, limits, reservations, last- ### Keep the Settings job singular -The Live Voice card answers one operator question: which upstream Accounts may serve verified OAuth Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. Caller inputs and caller-to-account matching are absent from the UI. +The Live Voice card answers one operator question: which upstream Accounts may serve verified OAuth Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. Caller inputs and caller-to-account matching are absent from the UI. The compact selector keeps selected unavailable Accounts visible so operators can identify and remove stale assignments while other unavailable Accounts remain excluded. ## Migration and rollback diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md index 311791043f..7b9558e502 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md @@ -26,7 +26,7 @@ The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` aga ### Requirement: OAuth identity validation is bounded and coalesced -Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. +Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Each process MUST admit at most 32 distinct in-flight credential validations; excess distinct misses MUST fail with a typed rate-limit response before database or upstream work begins. When the final waiter for an unfinished validation disconnects, the validation task MUST be cancelled and MUST continue consuming one admission slot until cancellation has drained. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. #### Scenario: Concurrent validation misses are coalesced @@ -34,3 +34,28 @@ Cache and singleflight keys MUST be a one-way digest over bearer and normalized - **WHEN** upstream validation is still in flight - **THEN** all callers await one shared validation - **AND** cache keys and diagnostics expose no raw credential + +#### Scenario: Distinct validation capacity is exhausted + +- **GIVEN** 32 distinct credential validations remain in flight on one process +- **WHEN** another uncached credential pair requests validation +- **THEN** the request fails with a typed rate-limit response +- **AND** no database or upstream validation begins for that pair + +#### Scenario: The final waiter disconnects + +- **GIVEN** one unfinished validation has no remaining request waiter +- **WHEN** its final waiter disconnects +- **THEN** the process cancels and drains the validation task +- **AND** the admission slot is released only after the task completes cancellation + +### Requirement: Aggregate Codex usage requires imported Account membership + +The OAuth-authenticated `/api/codex/usage` path SHALL authorize only a verified identity that resolves to one currently eligible imported Account. An independently verified external OAuth principal MAY use enabled OAuth Live routes but SHALL NOT receive the operator's aggregate local account-pool usage payload. Registered Proxy API Keys SHALL retain their existing usage behavior. + +#### Scenario: External OAuth principal requests aggregate usage + +- **GIVEN** valid OAuth credentials resolve to a stable principal without an eligible imported Account +- **WHEN** the caller requests `/api/codex/usage` +- **THEN** authorization fails before aggregate usage is read +- **AND** the same principal remains eligible for OAuth Live when the global policy permits it diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md index 7e1adb6e4f..5eef5de59d 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md @@ -22,3 +22,10 @@ The Settings page SHALL expose one Live Voice card with a global OAuth enable sw - **WHEN** a read-only dashboard user views Live Voice settings - **THEN** active state and selected upstream Accounts remain visible - **AND** switch, selector, and save action remain disabled + +#### Scenario: Selected Account becomes unavailable + +- **GIVEN** an Account selected in the OAuth Live pool becomes paused, reauthentication-required, or deactivated +- **WHEN** a dashboard writer opens the compact Account selector +- **THEN** the selected unavailable Account remains identifiable and selected +- **AND** the writer can remove it while unselected unavailable Accounts remain hidden diff --git a/openspec/specs/account-identity/spec.md b/openspec/specs/account-identity/spec.md index f22c26f7ba..97a8124ace 100644 --- a/openspec/specs/account-identity/spec.md +++ b/openspec/specs/account-identity/spec.md @@ -48,7 +48,7 @@ The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` aga ### Requirement: OAuth identity validation is bounded and coalesced -Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. +Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Each process MUST admit at most 32 distinct in-flight credential validations; excess distinct misses MUST fail with a typed rate-limit response before database or upstream work begins. When the final waiter for an unfinished validation disconnects, the validation task MUST be cancelled and MUST continue consuming one admission slot until cancellation has drained. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. #### Scenario: Concurrent validation misses are coalesced @@ -56,3 +56,28 @@ Cache and singleflight keys MUST be a one-way digest over bearer and normalized - **WHEN** upstream validation is still in flight - **THEN** all callers await one shared validation - **AND** cache keys and diagnostics expose no raw credential + +#### Scenario: Distinct validation capacity is exhausted + +- **GIVEN** 32 distinct credential validations remain in flight on one process +- **WHEN** another uncached credential pair requests validation +- **THEN** the request fails with a typed rate-limit response +- **AND** no database or upstream validation begins for that pair + +#### Scenario: The final waiter disconnects + +- **GIVEN** one unfinished validation has no remaining request waiter +- **WHEN** its final waiter disconnects +- **THEN** the process cancels and drains the validation task +- **AND** the admission slot is released only after the task completes cancellation + +### Requirement: Aggregate Codex usage requires imported Account membership + +The OAuth-authenticated `/api/codex/usage` path SHALL authorize only a verified identity that resolves to one currently eligible imported Account. An independently verified external OAuth principal MAY use enabled OAuth Live routes but SHALL NOT receive the operator's aggregate local account-pool usage payload. Registered Proxy API Keys SHALL retain their existing usage behavior. + +#### Scenario: External OAuth principal requests aggregate usage + +- **GIVEN** valid OAuth credentials resolve to a stable principal without an eligible imported Account +- **WHEN** the caller requests `/api/codex/usage` +- **THEN** authorization fails before aggregate usage is read +- **AND** the same principal remains eligible for OAuth Live when the global policy permits it diff --git a/openspec/specs/frontend-architecture/spec.md b/openspec/specs/frontend-architecture/spec.md index f2b0b2e379..9dd1c4ffd7 100644 --- a/openspec/specs/frontend-architecture/spec.md +++ b/openspec/specs/frontend-architecture/spec.md @@ -2406,3 +2406,10 @@ The Settings page SHALL expose one Live Voice card with a global OAuth enable sw - **WHEN** a read-only dashboard user views Live Voice settings - **THEN** active state and selected upstream Accounts remain visible - **AND** switch, selector, and save action remain disabled + +#### Scenario: Selected Account becomes unavailable + +- **GIVEN** an Account selected in the OAuth Live pool becomes paused, reauthentication-required, or deactivated +- **WHEN** a dashboard writer opens the compact Account selector +- **THEN** the selected unavailable Account remains identifiable and selected +- **AND** the writer can remove it while unselected unavailable Accounts remain hidden diff --git a/tests/integration/test_codex_usage_api.py b/tests/integration/test_codex_usage_api.py index ddb871698f..e674281b1b 100644 --- a/tests/integration/test_codex_usage_api.py +++ b/tests/integration/test_codex_usage_api.py @@ -1,5 +1,7 @@ from __future__ import annotations +import base64 +import json from datetime import timedelta, timezone import pytest @@ -22,6 +24,11 @@ pytestmark = pytest.mark.integration +def _jwt(payload: dict[str, object]) -> str: + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + return f"header.{encoded}.signature" + + def _make_account( account_id: str, email: str, @@ -88,6 +95,28 @@ async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: ob clear_codex_oauth_identity_cache() +@pytest.mark.asyncio +async def test_codex_usage_rejects_external_verified_oauth_principal(async_client, db_setup, monkeypatch): + expected_access_token = _jwt({"sub": "external-user"}) + + async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: object) -> UsagePayload: + assert access_token == expected_access_token + return UsagePayload.model_validate({"plan_type": "plus", "workspace_id": account_id}) + + monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + + response = await async_client.get( + "/api/codex/usage", + headers={ + "Authorization": f"Bearer {expected_access_token}", + "chatgpt-account-id": "workspace_external", + }, + ) + + assert response.status_code == 401 + assert response.json()["error"]["code"] == "invalid_api_key" + + @pytest.mark.asyncio async def test_codex_usage_aggregates_windows(async_client, db_setup): now_epoch = int(utcnow().replace(tzinfo=timezone.utc).timestamp()) diff --git a/tests/unit/test_auth_dependencies_upstream_proxy.py b/tests/unit/test_auth_dependencies_upstream_proxy.py index a6d01d3c15..053f7a6ce8 100644 --- a/tests/unit/test_auth_dependencies_upstream_proxy.py +++ b/tests/unit/test_auth_dependencies_upstream_proxy.py @@ -58,6 +58,36 @@ async def resolve_identity( assert request.state.codex_usage_identity_payload is usage_payload +@pytest.mark.asyncio +async def test_validate_codex_usage_identity_rejects_external_oauth_principal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request = SimpleNamespace( + headers={"Authorization": "Bearer oauth-token", "chatgpt-account-id": "workspace_account"}, + state=SimpleNamespace(), + ) + identity = VerifiedCodexOAuthIdentity( + principal_id="principal:user-external", + caller_account_id=None, + chatgpt_account_id="workspace_account", + usage_payload=UsagePayload(workspace_id="workspace_1"), + route=None, + ) + + async def resolve_identity( + authorization: str | None, + chatgpt_account_id: str | None, + ) -> VerifiedCodexOAuthIdentity: + return identity + + monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", resolve_identity) + + with pytest.raises(ProxyAuthError, match="eligible imported account"): + await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) + + assert not hasattr(request.state, "codex_usage_identity_payload") + + @pytest.mark.asyncio async def test_validate_codex_usage_identity_keeps_proxy_key_on_key_path( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_codex_oauth_identity.py b/tests/unit/test_codex_oauth_identity.py index aa74c48712..11f219f80d 100644 --- a/tests/unit/test_codex_oauth_identity.py +++ b/tests/unit/test_codex_oauth_identity.py @@ -432,6 +432,91 @@ async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: assert fetch_count == 1 +@pytest.mark.asyncio +async def test_cancelled_only_waiter_cancels_and_drains_validation(monkeypatch: pytest.MonkeyPatch) -> None: + fetch_started = asyncio.Event() + fetch_cancelled = asyncio.Event() + release_fetch = asyncio.Event() + release_cancellation = asyncio.Event() + access_token = _jwt({"chatgpt_user_id": "user_a"}) + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + fetch_started.set() + try: + await release_fetch.wait() + except asyncio.CancelledError: + fetch_cancelled.set() + await release_cancellation.wait() + raise + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, fetch_usage) + waiter = asyncio.create_task( + codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + ) + + await fetch_started.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + + await asyncio.wait_for(fetch_cancelled.wait(), timeout=1.0) + assert len(codex_oauth_identity._identity_inflight) == 1 + with pytest.raises(ProxyRateLimitError, match="still being cancelled"): + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {access_token}", + "workspace_account", + ) + + release_cancellation.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert codex_oauth_identity._identity_inflight == {} + + +@pytest.mark.asyncio +async def test_distinct_identity_validation_capacity_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + fetch_count = 0 + all_fetches_started = asyncio.Event() + release_fetches = asyncio.Event() + + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + nonlocal fetch_count + fetch_count += 1 + if fetch_count == codex_oauth_identity._INFLIGHT_MAX_ENTRIES: + all_fetches_started.set() + await release_fetches.wait() + return UsagePayload(workspace_id="workspace_1") + + _install_single_account_fakes(monkeypatch, fetch_usage) + waiters = [ + asyncio.create_task( + codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {_jwt({'chatgpt_user_id': 'user_a', 'nonce': index})}", + "workspace_account", + ) + ) + for index in range(codex_oauth_identity._INFLIGHT_MAX_ENTRIES) + ] + + await asyncio.wait_for(all_fetches_started.wait(), timeout=1.0) + overflow_token = _jwt({"chatgpt_user_id": "user_a", "nonce": "overflow"}) + with pytest.raises(ProxyRateLimitError, match="Too many concurrent"): + await codex_oauth_identity.resolve_verified_codex_oauth_identity( + f"Bearer {overflow_token}", + "workspace_account", + ) + assert fetch_count == codex_oauth_identity._INFLIGHT_MAX_ENTRIES + + release_fetches.set() + await asyncio.gather(*waiters) + await asyncio.sleep(0) + assert codex_oauth_identity._identity_inflight == {} + + @pytest.mark.parametrize( ("status_code", "expected_error"), [(429, ProxyRateLimitError), (503, ProxyUpstreamError)], From 0d3ceefe24b644bb06e2e10147c02ca23ea4a1e8 Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 10:14:30 +0800 Subject: [PATCH 5/9] fix(oauth-live): reuse keyless proxy admission --- app/core/auth/__init__.py | 22 - app/core/auth/codex_oauth_identity.py | 375 ----------- app/core/auth/dependencies.py | 96 ++- app/modules/accounts/repository.py | 11 - app/modules/proxy/api.py | 4 +- app/modules/proxy/realtime_auth.py | 54 +- docs/live-voice.md | 19 +- frontend/src/i18n/locales/en.json | 6 +- frontend/src/i18n/locales/ko.json | 6 +- frontend/src/i18n/locales/zh-CN.json | 6 +- .../add-oauth-live-voice-auth/context.md | 16 +- .../add-oauth-live-voice-auth/design.md | 44 +- .../add-oauth-live-voice-auth/proposal.md | 14 +- .../specs/account-identity/spec.md | 61 -- .../specs/frontend-architecture/spec.md | 2 +- .../specs/realtime-api-compat/spec.md | 24 +- .../add-oauth-live-voice-auth/tasks.md | 10 +- openspec/specs/account-identity/spec.md | 59 -- openspec/specs/frontend-architecture/spec.md | 2 +- openspec/specs/realtime-api-compat/context.md | 39 +- openspec/specs/realtime-api-compat/spec.md | 24 +- tests/integration/test_accounts_repository.py | 35 - tests/integration/test_auth_middleware.py | 4 +- tests/integration/test_codex_usage_api.py | 44 +- tests/integration/test_proxy_realtime_live.py | 115 +++- .../integration/test_proxy_sticky_sessions.py | 6 +- .../test_auth_dependencies_upstream_proxy.py | 296 ++++++-- tests/unit/test_codex_oauth_identity.py | 636 ------------------ tests/unit/test_realtime_auth.py | 225 ++++++- tests/unit/test_realtime_live.py | 4 +- 30 files changed, 803 insertions(+), 1456 deletions(-) delete mode 100644 app/core/auth/codex_oauth_identity.py delete mode 100644 openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md delete mode 100644 tests/unit/test_codex_oauth_identity.py diff --git a/app/core/auth/__init__.py b/app/core/auth/__init__.py index 7665254e6c..c90a1f89cd 100644 --- a/app/core/auth/__init__.py +++ b/app/core/auth/__init__.py @@ -149,28 +149,6 @@ def resolve_seat_identity(claims: "IdTokenClaims", auth_claims: "OpenAIAuthClaim return clean_account_identity_part(resolved_auth.chatgpt_user_id or claims.chatgpt_user_id or claims.sub) -def resolve_seat_identity_aliases( - claims: "IdTokenClaims", - auth_claims: "OpenAIAuthClaims | None" = None, -) -> frozenset[str]: - """Return every stable per-seat principal alias carried by one JWT. - - Access and ID tokens can describe the same seat with different claim names: - ``chatgpt_user_id`` commonly uses a ``user-...`` value while ``sub`` can use - an Auth0 or social-login principal. Keeping all verified aliases lets callers - match an access token to the imported account's stored ID token without - assuming those two identifiers are byte-identical. - """ - - resolved_auth = auth_claims if auth_claims is not None else (claims.auth or OpenAIAuthClaims()) - aliases = ( - clean_account_identity_part(resolved_auth.chatgpt_user_id), - clean_account_identity_part(claims.chatgpt_user_id), - clean_account_identity_part(claims.sub), - ) - return frozenset(alias for alias in aliases if alias is not None) - - def parse_auth_json(raw: bytes) -> AuthFile: data = json.loads(raw) model = AuthFile.model_validate(data) diff --git a/app/core/auth/codex_oauth_identity.py b/app/core/auth/codex_oauth_identity.py deleted file mode 100644 index 828bef52ec..0000000000 --- a/app/core/auth/codex_oauth_identity.py +++ /dev/null @@ -1,375 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import time -from collections import OrderedDict -from dataclasses import dataclass - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.auth import ( - clean_account_identity_part, - extract_id_token_claims, - resolve_seat_identity, - resolve_seat_identity_aliases, - token_expiry_epoch_ms, -) -from app.core.clients.usage import UsageFetchError, fetch_usage -from app.core.crypto import TokenEncryptor -from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError -from app.core.upstream_proxy import ResolvedUpstreamRoute, UpstreamProxyRouteError, resolve_upstream_route -from app.core.usage.models import UsagePayload -from app.db.models import Account -from app.db.session import get_background_session -from app.modules.accounts.repository import AccountsRepository - -_POSITIVE_CACHE_TTL_SECONDS = 60.0 -_DENIAL_CACHE_TTL_SECONDS = 5.0 -_CACHE_MAX_ENTRIES = 256 -_INFLIGHT_MAX_ENTRIES = 32 - - -@dataclass(frozen=True, slots=True) -class VerifiedCodexOAuthIdentity: - principal_id: str - caller_account_id: str | None - chatgpt_account_id: str - usage_payload: UsagePayload - route: ResolvedUpstreamRoute | None - - -@dataclass(frozen=True, slots=True) -class _CachedDenial: - message: str - - -@dataclass(frozen=True, slots=True) -class _CacheEntry: - value: VerifiedCodexOAuthIdentity | _CachedDenial - expires_at: float - - -@dataclass(slots=True) -class _InflightValidation: - task: asyncio.Task[VerifiedCodexOAuthIdentity] - waiters: int - - -_identity_cache: OrderedDict[str, _CacheEntry] = OrderedDict() -_identity_inflight: dict[str, _InflightValidation] = {} -_identity_lock = asyncio.Lock() - - -def clear_codex_oauth_identity_cache() -> None: - """Clear process-local identity state (used by tests and lifecycle resets).""" - - _identity_cache.clear() - for validation in _identity_inflight.values(): - validation.task.cancel() - _identity_inflight.clear() - - -async def resolve_verified_codex_oauth_identity( - authorization: str | None, - chatgpt_account_id: str | None, -) -> VerifiedCodexOAuthIdentity: - token = _extract_bearer_token(authorization) - if token is None: - raise ProxyAuthError("Missing ChatGPT token in Authorization header") - - normalized_account_id = clean_account_identity_part(chatgpt_account_id) - if normalized_account_id is None: - raise ProxyAuthError("Missing chatgpt-account-id header") - - cache_key = _credential_digest(token, normalized_account_id) - async with _identity_lock: - cached = _get_cached_locked(cache_key) - if cached is not None: - if isinstance(cached, _CachedDenial): - raise ProxyAuthError(cached.message) - return cached - - validation = _identity_inflight.get(cache_key) - if validation is None: - if len(_identity_inflight) >= _INFLIGHT_MAX_ENTRIES: - raise ProxyRateLimitError("Too many concurrent ChatGPT credential validations") - task = asyncio.create_task( - _validate_and_cache_identity( - cache_key=cache_key, - access_token=token, - chatgpt_account_id=normalized_account_id, - ) - ) - validation = _InflightValidation(task=task, waiters=0) - _identity_inflight[cache_key] = validation - task.add_done_callback(lambda completed, key=cache_key: _remove_inflight(key, completed)) - elif validation.waiters == 0: - raise ProxyRateLimitError("ChatGPT credential validation is still being cancelled") - validation.waiters += 1 - - try: - return await asyncio.shield(validation.task) - finally: - await _release_inflight_waiter(cache_key, validation.task) - - -async def _validate_and_cache_identity( - *, - cache_key: str, - access_token: str, - chatgpt_account_id: str, -) -> VerifiedCodexOAuthIdentity: - try: - identity = await _validate_identity_uncached( - access_token=access_token, - chatgpt_account_id=chatgpt_account_id, - ) - except ProxyAuthError as exc: - async with _identity_lock: - _set_cached_locked( - cache_key, - _CachedDenial(exc.message), - ttl_seconds=_DENIAL_CACHE_TTL_SECONDS, - ) - raise - - ttl_seconds = _positive_cache_ttl(access_token) - if ttl_seconds > 0: - async with _identity_lock: - _set_cached_locked(cache_key, identity, ttl_seconds=ttl_seconds) - return identity - - -async def _validate_identity_uncached( - *, - access_token: str, - chatgpt_account_id: str, -) -> VerifiedCodexOAuthIdentity: - stable_principal = resolve_seat_identity(extract_id_token_claims(access_token)) - validation_account_id: str | None = None - validation_route: ResolvedUpstreamRoute | None = None - async with get_background_session() as session: - candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(chatgpt_account_id) - validation_candidates = _prefer_token_seat_alias(candidates, access_token, TokenEncryptor()) - if validation_candidates: - validation_account_id = validation_candidates[0].id - validation_route = await _resolve_route(session, validation_account_id) - elif stable_principal is None: - raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") - else: - validation_route = await _resolve_route(session, None) - - try: - usage_payload = await fetch_usage( - access_token=access_token, - account_id=chatgpt_account_id, - route=validation_route, - allow_direct_egress=validation_route is None, - ) - except UsageFetchError as exc: - if exc.status_code == 429: - raise ProxyRateLimitError("ChatGPT credential validation rate limited") from exc - if exc.status_code in (401, 403): - raise ProxyAuthError("Invalid ChatGPT token or chatgpt-account-id") from exc - raise ProxyUpstreamError("Unable to validate ChatGPT credentials at this time") from exc - - async with get_background_session() as session: - candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(chatgpt_account_id) - caller = _resolve_unique_caller( - access_token=access_token, - usage_payload=usage_payload, - candidates=candidates, - encryptor=TokenEncryptor(), - ) - if caller is None and stable_principal is None: - raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") - caller_account_id = caller.id if caller is not None else None - if stable_principal is not None: - principal_id = f"principal:{stable_principal}" - elif caller_account_id is not None: - principal_id = caller_account_id - else: - raise ProxyAuthError("Unknown or ambiguous ChatGPT identity") - - if caller_account_id is None: - route = validation_route - else: - route = ( - validation_route - if caller_account_id == validation_account_id - else await _resolve_route(session, caller_account_id) - ) - - return VerifiedCodexOAuthIdentity( - principal_id=principal_id, - caller_account_id=caller_account_id, - chatgpt_account_id=chatgpt_account_id, - usage_payload=usage_payload, - route=route, - ) - - -async def _resolve_route(session: AsyncSession, account_id: str | None) -> ResolvedUpstreamRoute | None: - try: - return await resolve_upstream_route( - session, - account_id=account_id, - operation="usage_identity", - scope="account", - encryptor=TokenEncryptor(), - ) - except UpstreamProxyRouteError as exc: - raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc - - -def _resolve_unique_caller( - *, - access_token: str, - usage_payload: UsagePayload, - candidates: list[Account], - encryptor: TokenEncryptor, -) -> Account | None: - contextual_candidates = _prefer_exact_verified_workspace(candidates, usage_payload) - contextual_candidates = _prefer_token_seat_alias(contextual_candidates, access_token, encryptor) - return contextual_candidates[0] if len(contextual_candidates) == 1 else None - - -def _prefer_token_seat_alias( - candidates: list[Account], - access_token: str, - encryptor: TokenEncryptor, -) -> list[Account]: - token_aliases = resolve_seat_identity_aliases(extract_id_token_claims(access_token)) - if not token_aliases: - return candidates - return [ - account for account in candidates if token_aliases.intersection(_account_identity_aliases(account, encryptor)) - ] - - -def _prefer_exact_verified_workspace(candidates: list[Account], usage_payload: UsagePayload) -> list[Account]: - verified_workspace_id = clean_account_identity_part(usage_payload.workspace_id) - if verified_workspace_id is not None: - exact_workspace = [ - account - for account in candidates - if clean_account_identity_part(account.workspace_id) == verified_workspace_id - ] - if exact_workspace: - return exact_workspace - - verified_label = clean_account_identity_part(usage_payload.workspace_label) - if verified_workspace_id is None and verified_label is not None: - exact_label = [ - account - for account in candidates - if (clean_account_identity_part(account.workspace_label) or "").casefold() == verified_label.casefold() - ] - if exact_label: - return exact_label - - return [account for account in candidates if _matches_verified_workspace(account, usage_payload)] - - -def _matches_verified_workspace(account: Account, usage_payload: UsagePayload) -> bool: - verified_workspace_id = clean_account_identity_part(usage_payload.workspace_id) - account_workspace_id = clean_account_identity_part(account.workspace_id) - if ( - verified_workspace_id is not None - and account_workspace_id is not None - and verified_workspace_id != account_workspace_id - ): - return False - - verified_label = clean_account_identity_part(usage_payload.workspace_label) - account_label = clean_account_identity_part(account.workspace_label) - return not ( - verified_workspace_id is None - and verified_label is not None - and account_label is not None - and verified_label.casefold() != account_label.casefold() - ) - - -def _account_identity_aliases(account: Account, encryptor: TokenEncryptor) -> frozenset[str]: - aliases = set() - account_user_id = clean_account_identity_part(account.chatgpt_user_id) - if account_user_id is not None: - aliases.add(account_user_id) - try: - id_token = encryptor.decrypt(account.id_token_encrypted) - except Exception: - return frozenset(aliases) - aliases.update(resolve_seat_identity_aliases(extract_id_token_claims(id_token))) - return frozenset(aliases) - - -def _extract_bearer_token(authorization: str | None) -> str | None: - if authorization is None: - return None - value = authorization.strip() - prefix = "bearer " - if not value.lower().startswith(prefix): - return None - return clean_account_identity_part(value[len(prefix) :]) - - -def _credential_digest(access_token: str, chatgpt_account_id: str) -> str: - material = f"{access_token}\0{chatgpt_account_id}".encode() - return hashlib.sha256(material).hexdigest() - - -def _positive_cache_ttl(access_token: str) -> float: - expires_at_ms = token_expiry_epoch_ms(access_token) - if expires_at_ms is None: - return _POSITIVE_CACHE_TTL_SECONDS - remaining_seconds = expires_at_ms / 1000.0 - time.time() - return max(0.0, min(_POSITIVE_CACHE_TTL_SECONDS, remaining_seconds)) - - -def _get_cached_locked(cache_key: str) -> VerifiedCodexOAuthIdentity | _CachedDenial | None: - entry = _identity_cache.get(cache_key) - if entry is None: - return None - if entry.expires_at <= time.monotonic(): - _identity_cache.pop(cache_key, None) - return None - _identity_cache.move_to_end(cache_key) - return entry.value - - -def _set_cached_locked( - cache_key: str, - value: VerifiedCodexOAuthIdentity | _CachedDenial, - *, - ttl_seconds: float, -) -> None: - _identity_cache[cache_key] = _CacheEntry(value=value, expires_at=time.monotonic() + ttl_seconds) - _identity_cache.move_to_end(cache_key) - while len(_identity_cache) > _CACHE_MAX_ENTRIES: - _identity_cache.popitem(last=False) - - -def _remove_inflight( - cache_key: str, - completed: asyncio.Task[VerifiedCodexOAuthIdentity], -) -> None: - validation = _identity_inflight.get(cache_key) - if validation is not None and validation.task is completed: - _identity_inflight.pop(cache_key, None) - if not completed.cancelled(): - completed.exception() - - -async def _release_inflight_waiter( - cache_key: str, - task: asyncio.Task[VerifiedCodexOAuthIdentity], -) -> None: - async with _identity_lock: - validation = _identity_inflight.get(cache_key) - if validation is None or validation.task is not task: - return - validation.waiters -= 1 - if validation.waiters == 0 and not task.done(): - task.cancel() diff --git a/app/core/auth/dependencies.py b/app/core/auth/dependencies.py index 7ed45d8d2b..11ced12726 100644 --- a/app/core/auth/dependencies.py +++ b/app/core/auth/dependencies.py @@ -9,8 +9,8 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from starlette.requests import HTTPConnection +from app.core.auth import generate_unique_account_id from app.core.auth.api_key_cache import get_api_key_cache -from app.core.auth.codex_oauth_identity import resolve_verified_codex_oauth_identity from app.core.auth.dashboard_access import ( DashboardPermission, DashboardPrincipal, @@ -19,13 +19,18 @@ guest_principal, ) from app.core.auth.dashboard_mode import DashboardAuthMode, get_dashboard_request_auth +from app.core.clients.usage import UsageFetchError, fetch_usage from app.core.config.settings import get_settings from app.core.config.settings_cache import get_settings_cache -from app.core.exceptions import DashboardAuthError, DashboardPermissionError, ProxyAuthError +from app.core.crypto import TokenEncryptor +from app.core.exceptions import DashboardAuthError, DashboardPermissionError, ProxyAuthError, ProxyUpstreamError from app.core.request_locality import is_local_request from app.core.socket_peer import raw_socket_peer_host +from app.core.upstream_proxy import UpstreamProxyRouteError, resolve_upstream_route from app.core.utils.time import utcnow +from app.db.models import AccountStatus from app.db.session import get_background_session +from app.modules.accounts.repository import AccountsRepository from app.modules.api_keys.repository import ApiKeysRepository from app.modules.api_keys.service import ApiKeyData, ApiKeyInvalidError, ApiKeysService from app.modules.dashboard_auth.service import DASHBOARD_SESSION_COOKIE, get_dashboard_session_store @@ -33,6 +38,13 @@ logger = logging.getLogger(__name__) _bearer = HTTPBearer(description="API key (e.g. sk-clb-…)", auto_error=False) +_CODEX_USAGE_IDENTITY_INACTIVE_WORKSPACE_STATUSES = { + AccountStatus.PAUSED, + AccountStatus.REAUTH_REQUIRED, + AccountStatus.DEACTIVATED, +} + + # --- Error format markers --- @@ -282,21 +294,75 @@ async def validate_codex_usage_identity(request: Request) -> ApiKeyData | None: if not token: raise ProxyAuthError("Missing ChatGPT token in Authorization header") - if token.startswith("sk-clb-"): - return await _validate_api_key_token(token) + raw_account_id = request.headers.get("chatgpt-account-id") + account_id = raw_account_id.strip() if raw_account_id else "" + if not account_id: + if token.startswith("sk-clb-"): + return await _validate_api_key_token(token) + raise ProxyAuthError("Missing chatgpt-account-id header") - identity = await resolve_verified_codex_oauth_identity( - request.headers.get("Authorization"), - request.headers.get("chatgpt-account-id"), - ) - if identity.caller_account_id is None: - raise ProxyAuthError("ChatGPT identity is not an eligible imported account") + async with get_background_session() as session: + accounts_repo = AccountsRepository(session) + account = await accounts_repo.get_active_by_chatgpt_account_id(account_id) + if account is None: + raise ProxyAuthError("Unknown or inactive chatgpt-account-id") + local_account_id = account.id + local_account_email = account.email + try: + route = await resolve_upstream_route( + session, + account_id=local_account_id, + operation="usage_identity", + scope="account", + encryptor=TokenEncryptor(), + ) + except UpstreamProxyRouteError as exc: + raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc + + try: + usage_payload = await fetch_usage( + access_token=token, + account_id=account_id, + route=route, + allow_direct_egress=route is None, + ) + except UsageFetchError as exc: + if exc.status_code == 429: + from app.core.exceptions import ProxyRateLimitError + + raise ProxyRateLimitError(exc.message) from exc + if exc.status_code in (401, 403): + raise ProxyAuthError("Invalid ChatGPT token or chatgpt-account-id") from exc + raise ProxyUpstreamError("Unable to validate ChatGPT credentials at this time") from exc + if usage_payload is not None and (usage_payload.workspace_id or usage_payload.workspace_label): + expected_account_id = generate_unique_account_id( + account_id, + local_account_email, + usage_payload.workspace_id, + usage_payload.workspace_label, + ) + async with get_background_session() as session: + accounts_repo = AccountsRepository(session) + workspace_account = await accounts_repo.get_by_id(expected_account_id) + if workspace_account is not None and workspace_account.chatgpt_account_id == account_id: + if workspace_account.status in _CODEX_USAGE_IDENTITY_INACTIVE_WORKSPACE_STATUSES: + raise ProxyAuthError("Unknown or inactive chatgpt-account-id") + local_account_id = workspace_account.id + try: + route = await resolve_upstream_route( + session, + account_id=local_account_id, + operation="usage_identity", + scope="account", + encryptor=TokenEncryptor(), + ) + except UpstreamProxyRouteError as exc: + raise ProxyUpstreamError("Unable to resolve upstream proxy route for ChatGPT credentials") from exc request.state.codex_usage_identity_access_token = token - request.state.codex_usage_identity_chatgpt_account_id = identity.chatgpt_account_id - request.state.codex_usage_identity_account_id = identity.caller_account_id - request.state.codex_usage_identity_principal_id = identity.principal_id - request.state.codex_usage_identity_route = identity.route - request.state.codex_usage_identity_payload = identity.usage_payload + request.state.codex_usage_identity_chatgpt_account_id = account_id + request.state.codex_usage_identity_account_id = local_account_id + request.state.codex_usage_identity_route = route + request.state.codex_usage_identity_payload = usage_payload return None diff --git a/app/modules/accounts/repository.py b/app/modules/accounts/repository.py index 35b025aa0e..1e8e5e1232 100644 --- a/app/modules/accounts/repository.py +++ b/app/modules/accounts/repository.py @@ -168,17 +168,6 @@ async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Acc ) return result.scalar_one_or_none() - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - result = await self._session.execute( - select(Account) - .where(Account.chatgpt_account_id == chatgpt_account_id) - .where( - Account.status.notin_((AccountStatus.REAUTH_REQUIRED, AccountStatus.DEACTIVATED, AccountStatus.PAUSED)) - ) - .order_by(Account.id) - ) - return list(result.scalars().all()) - async def upsert( self, account: Account, diff --git a/app/modules/proxy/api.py b/app/modules/proxy/api.py index 7ad0b72749..1f159adc61 100644 --- a/app/modules/proxy/api.py +++ b/app/modules/proxy/api.py @@ -935,6 +935,7 @@ async def codex_realtime_calls( context: ProxyContext = Depends(get_proxy_context), ) -> Response: caller_scope = await resolve_realtime_caller_scope( + request, request.headers.get("authorization"), request.headers.get("chatgpt-account-id"), api_key_validator=validate_required_proxy_api_key_authorization, @@ -6291,11 +6292,12 @@ async def _resolve_realtime_caller_websocket_request( ) -> tuple[RealtimeCallerScope | None, JSONResponse | None]: try: caller_scope = await resolve_realtime_caller_scope( + websocket, websocket.headers.get("authorization"), websocket.headers.get("chatgpt-account-id"), api_key_validator=validate_required_proxy_api_key_authorization, ) - except (ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError) as exc: + except ProxyAuthError as exc: return None, JSONResponse( status_code=exc.status_code, content=openai_error(exc.code, exc.message, error_type=exc.error_type), diff --git a/app/modules/proxy/realtime_auth.py b/app/modules/proxy/realtime_auth.py index 0ddcf545a5..af24690d5f 100644 --- a/app/modules/proxy/realtime_auth.py +++ b/app/modules/proxy/realtime_auth.py @@ -1,16 +1,29 @@ from __future__ import annotations +import hashlib +import hmac from collections.abc import Awaitable, Callable, Collection from dataclasses import dataclass from typing import Literal -from app.core.auth.dependencies import validate_required_proxy_api_key_authorization +from starlette.requests import HTTPConnection + +from app.core.auth import clean_account_identity_part +from app.core.auth.dependencies import ( + validate_proxy_api_key_authorization, + validate_required_proxy_api_key_authorization, +) +from app.core.crypto import get_or_create_key from app.core.exceptions import ProxyAuthError from app.db.session import get_background_session from app.modules.api_keys.service import ApiKeyData RealtimeCallerKind = Literal["api_key", "oauth"] ApiKeyValidator = Callable[[str | None], Awaitable[ApiKeyData]] +KeylessOriginValidator = Callable[[HTTPConnection], Awaitable[None]] +AffinityMaterialBuilder = Callable[[str, str], str] + +_OAUTH_LIVE_AFFINITY_DOMAIN = b"codex-lb/oauth-live-affinity/v1" class OAuthLiveNotEnabledError(ProxyAuthError): @@ -25,7 +38,6 @@ class RealtimeCallerScope: kind: RealtimeCallerKind affinity_scope_material: str api_key: ApiKeyData | None - oauth_principal_id: str | None allowed_account_ids: frozenset[str] | None @classmethod @@ -34,7 +46,6 @@ def for_api_key(cls, api_key: ApiKeyData) -> RealtimeCallerScope: kind="api_key", affinity_scope_material=api_key.id, api_key=api_key, - oauth_principal_id=None, allowed_account_ids=None, ) @@ -42,7 +53,7 @@ def for_api_key(cls, api_key: ApiKeyData) -> RealtimeCallerScope: def for_oauth( cls, *, - principal_id: str, + affinity_scope_material: str, allowed_account_ids: Collection[str], ) -> RealtimeCallerScope: allowed = frozenset(allowed_account_ids) @@ -50,9 +61,8 @@ def for_oauth( raise OAuthLiveNotEnabledError() return cls( kind="oauth", - affinity_scope_material=f"oauth:{principal_id}", + affinity_scope_material=affinity_scope_material, api_key=None, - oauth_principal_id=principal_id, allowed_account_ids=allowed, ) @@ -85,21 +95,47 @@ async def _active_oauth_live_allowed_account_ids() -> frozenset[str]: return await OAuthLivePolicyRepository(session).get_active_allowed_account_ids() +async def _validate_keyless_origin(connection: HTTPConnection) -> None: + # Reuse the ordinary proxy's zero-key admission contract. Passing no bearer + # makes global API-key mode fail closed; disabled mode still enforces the + # existing loopback/proxy-chain/raw-socket allowlist checks. + await validate_proxy_api_key_authorization(None, request=connection) + + +def _oauth_live_affinity_scope_material(access_token: str, chatgpt_account_id: str) -> str: + # Derive a purpose-specific HMAC key from the existing persistent encryption + # key. Replicas that can decrypt the same account store therefore derive the + # same affinity material without adding another secret or setting. + root_key = get_or_create_key() + affinity_key = hmac.digest(root_key, _OAUTH_LIVE_AFFINITY_DOMAIN, hashlib.sha256) + credential_pair = f"{access_token}\0{chatgpt_account_id}".encode() + digest = hmac.new(affinity_key, credential_pair, hashlib.sha256).hexdigest() + return f"oauth-local:{digest}" + + async def resolve_realtime_caller_scope( + connection: HTTPConnection, authorization: str | None, chatgpt_account_id: str | None, *, api_key_validator: ApiKeyValidator = validate_required_proxy_api_key_authorization, + keyless_origin_validator: KeylessOriginValidator = _validate_keyless_origin, + affinity_material_builder: AffinityMaterialBuilder = _oauth_live_affinity_scope_material, ) -> RealtimeCallerScope: token = _extract_bearer_token(authorization) if token is not None and token.startswith("sk-clb-"): return RealtimeCallerScope.for_api_key(await api_key_validator(authorization)) - from app.core.auth.codex_oauth_identity import resolve_verified_codex_oauth_identity + await keyless_origin_validator(connection) + if token is None: + raise ProxyAuthError("Missing ChatGPT token in Authorization header") + + normalized_account_id = clean_account_identity_part(chatgpt_account_id) + if normalized_account_id is None: + raise ProxyAuthError("Missing chatgpt-account-id header") - identity = await resolve_verified_codex_oauth_identity(authorization, chatgpt_account_id) allowed_account_ids = await _active_oauth_live_allowed_account_ids() return RealtimeCallerScope.for_oauth( - principal_id=identity.principal_id, + affinity_scope_material=affinity_material_builder(token, normalized_account_id), allowed_account_ids=allowed_account_ids, ) diff --git a/docs/live-voice.md b/docs/live-voice.md index 2d86703c00..0f9cb3d392 100644 --- a/docs/live-voice.md +++ b/docs/live-voice.md @@ -10,15 +10,17 @@ codex-lb keeps Live Voice call creation and its control sideband on the same ups The same private routes accept two caller types: - Registered [proxy API keys](api-keys.md) keep their existing account assignments, limits, attribution, and affinity behavior. -- Official Codex OAuth credentials use the global policy under **Settings → Live Voice**. Every verified OAuth principal shares the configured upstream account pool. +- Official Codex OAuth credentials use the global policy under **Settings → Live Voice** after passing the ordinary zero-key proxy origin check. -An OAuth caller can remain independent from the imported upstream accounts. codex-lb validates its bearer and `chatgpt-account-id` against OpenAI, derives a stable principal for call ownership, and selects the serving account only from the global pool. The policy starts disabled and requires at least one selected upstream account before activation. +The OAuth lane is available when global proxy API-key authentication is disabled and the request comes from loopback or an existing explicitly allowed raw socket CIDR. Loopback needs no CIDR configuration. Other remote clients use registered Proxy API Keys. + +codex-lb derives an opaque caller scope locally from the bearer and normalized `chatgpt-account-id`. It does not call OpenAI usage to authenticate Live requests. The network boundary grants keyless access; the credential pair separates call ownership between admitted clients. ## Built-in OpenAI provider (OAuth) Use this profile when Codex must retain the built-in `openai` provider. It keeps official ChatGPT OAuth for conversations and Live Voice and requires no Codex-LB API Key in the client. -Enable **Settings → Live Voice → OAuth Live access**, select the upstream Accounts allowed to carry these calls, and route both Live Voice legs to codex-lb: +Enable **Settings → Live Voice → OAuth Live access**, select the upstream Accounts allowed to carry these calls, keep global proxy API-key authentication disabled, and route both Live Voice legs to codex-lb: ```toml model_provider = "openai" @@ -61,17 +63,20 @@ The current client appends `/realtime/calls` to the WebRTC base and `/realtime?i - `WS /v1/live/{call_id}` - `WS /v1/realtime?call_id={call_id}` -After call creation succeeds, codex-lb binds the returned call id to the final serving account under the authenticated caller scope. Every sideband form reloads that exact owner and confirms the current caller policy still allows it. Policy revocation, owner removal, unavailable accounts, and ownership mismatch all fail closed. +After call creation succeeds, codex-lb binds the returned call id to the final serving account under the caller scope. Every sideband form recomputes that scope, reloads the exact owner, and confirms the current policy still allows it. + +OAuth bearer rotation or encryption-key rotation changes the caller scope. A sideband using changed credentials receives the credential-safe not-found response and the client creates a new call. ## Privacy and request history -Ownership records contain a caller-scoped digest and the owning account reference. Credentials, raw call ids, SDP, attestation values, realtime frames, audio, and transcripts stay out of persistence and request payload traces. OAuth Live request logs use nullable API-key attribution. +Ownership records contain a caller-scoped digest and the owning account reference. Credentials, account headers, raw call ids, SDP, attestation values, realtime frames, audio, and transcripts stay out of persistence and request payload traces. OAuth Live request logs use nullable API-key attribution. ## Failure behavior -- `401 invalid_api_key`: caller authentication failed. +- `401 invalid_api_key`: caller authentication or zero-key origin admission failed. - `403 oauth_live_not_enabled`: the global OAuth Live policy is inactive or has no active eligible account. - `400 invalid_realtime_call_id`: the sideband supplied an invalid call id. +- `404 realtime_call_not_found`: ownership is missing or the credential pair changed. - `503 realtime_call_binding_failed`: a successful upstream call could not be bound safely. -*Specs: [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat) · [account-identity](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/account-identity) · [database-migrations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/database-migrations) · [frontend-architecture](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/frontend-architecture)* +*Specs: [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat) · [database-migrations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/database-migrations) · [frontend-architecture](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/frontend-architecture)* diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4424ebe09a..acb6a512d4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -979,13 +979,13 @@ "settings.page.disabledNotice": "Dashboard auth is fully bypassed by configuration. Only use this mode behind network restrictions or external access control.", "settings.page.savingLabel": "Saving settings...", "settings.oauthLive.title": "Live Voice", - "settings.oauthLive.description": "Route every Codex Live Voice caller verified by official OAuth through one shared account pool.", + "settings.oauthLive.description": "Route local keyless Codex Live Voice calls through one shared account pool.", "settings.oauthLive.pool.label": "Allowed upstream accounts", "settings.oauthLive.pool.description": "The shared upstream pool eligible to own new OAuth Live Voice calls.", "settings.oauthLive.pool.placeholder": "Select allowed accounts", "settings.oauthLive.toggle.label": "OAuth Live access", - "settings.oauthLive.toggle.description": "Allow verified official OAuth identities to create and control Live Voice calls.", - "settings.oauthLive.scopeNote": "This policy applies to every verified OAuth caller. API keys keep their own account assignments.", + "settings.oauthLive.toggle.description": "Allow OAuth Live calls admitted by the existing local or trusted proxy boundary.", + "settings.oauthLive.scopeNote": "This policy applies to keyless OAuth callers. API keys keep their own account assignments.", "settings.oauthLive.emptyError": "Select at least one allowed account before enabling OAuth Live Voice.", "settings.oauthLive.enableAria": "Enable OAuth Live Voice", "settings.oauthLive.loadFailed": "Failed to load OAuth Live Voice policy", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 4215e642b4..ae48af05cc 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -977,13 +977,13 @@ "settings.advanced.show": "고급 설정 표시", "settings.advanced.title": "고급 설정", "settings.oauthLive.title": "Live Voice", - "settings.oauthLive.description": "공식 OAuth로 검증된 모든 Codex Live Voice 호출자를 하나의 공유 Account pool로 라우팅합니다.", + "settings.oauthLive.description": "로컬 keyless Codex Live Voice 호출을 하나의 공유 Account pool로 라우팅합니다.", "settings.oauthLive.pool.label": "허용된 upstream Account", "settings.oauthLive.pool.description": "새 OAuth Live Voice 통화를 처리할 수 있는 공유 upstream Account pool입니다.", "settings.oauthLive.pool.placeholder": "허용할 Account 선택", "settings.oauthLive.toggle.label": "OAuth Live 접근", - "settings.oauthLive.toggle.description": "검증된 공식 OAuth identity가 Live Voice 통화를 생성하고 제어하도록 허용합니다.", - "settings.oauthLive.scopeNote": "이 policy는 검증된 모든 OAuth 호출자에게 적용됩니다. API key는 자체 Account 할당을 유지합니다.", + "settings.oauthLive.toggle.description": "기존 로컬 또는 신뢰 proxy 경계를 통과한 OAuth Live 호출을 허용합니다.", + "settings.oauthLive.scopeNote": "이 policy는 keyless OAuth 호출자에게 적용됩니다. API key는 자체 Account 할당을 유지합니다.", "settings.oauthLive.emptyError": "OAuth Live Voice를 활성화하기 전에 허용된 Account를 하나 이상 선택하세요.", "settings.oauthLive.enableAria": "OAuth Live Voice 활성화", "settings.oauthLive.loadFailed": "OAuth Live Voice policy를 불러오지 못했습니다", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b064dbf9e8..4345795208 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -979,13 +979,13 @@ "settings.page.disabledNotice": "仪表盘鉴权已被配置完全跳过。请仅在网络受限或外部已有访问控制的场景下使用此模式。", "settings.page.savingLabel": "保存设置中...", "settings.oauthLive.title": "Live Voice", - "settings.oauthLive.description": "所有通过官方 OAuth 验证的 Codex Live Voice 调用,统一从这里配置的账户池路由。", + "settings.oauthLive.description": "本机无 Key 的 Codex Live Voice 调用统一使用这里配置的账户池。", "settings.oauthLive.pool.label": "允许使用的上游账户", "settings.oauthLive.pool.description": "可承载 OAuth Live Voice 新通话的统一账户池。", "settings.oauthLive.pool.placeholder": "选择允许账户", "settings.oauthLive.toggle.label": "OAuth Live 权限", - "settings.oauthLive.toggle.description": "统一允许官方 OAuth 身份创建和控制 Live Voice 通话。", - "settings.oauthLive.scopeNote": "该策略作用于所有通过 OAuth 验证的调用者;API Key 继续使用各自的账户分配。", + "settings.oauthLive.toggle.description": "允许通过现有本机或可信代理边界的 OAuth 调用创建和控制 Live Voice。", + "settings.oauthLive.scopeNote": "该策略作用于无 Key OAuth 调用;API Key 继续使用各自的账户分配。", "settings.oauthLive.emptyError": "启用 OAuth Live Voice 前至少选择一个允许账户。", "settings.oauthLive.enableAria": "启用 OAuth Live Voice", "settings.oauthLive.loadFailed": "OAuth Live Voice 策略加载失败", diff --git a/openspec/changes/add-oauth-live-voice-auth/context.md b/openspec/changes/add-oauth-live-voice-auth/context.md index b753b3ed2c..089eec8a33 100644 --- a/openspec/changes/add-oauth-live-voice-auth/context.md +++ b/openspec/changes/add-oauth-live-voice-auth/context.md @@ -1,21 +1,23 @@ -# OAuth WebRTC Live Voice Context +# Keyless OAuth WebRTC Live Voice Context Normative behavior lives in this change's capability delta specs. This file records the client constraint and operator boundary. ## Client constraint -The built-in Codex `openai` provider sends official ChatGPT OAuth as request authorization. The client cannot attach a second Codex-LB Proxy API Key to that provider. The OAuth caller lane lets this first-party profile reach codex-lb while retaining the official provider identity, model behavior, account state, and Live Voice entry point. +The built-in Codex `openai` provider sends official ChatGPT OAuth as request authorization. The client cannot attach a second Codex-LB Proxy API Key to that provider. The keyless OAuth caller lane lets this first-party profile reach codex-lb from the same local/trusted origin boundary already used by ordinary zero-key proxy requests. -The existing registered-Key profile remains a custom provider named `openai`. It combines `requires_openai_auth = true` with `env_key = "CODEX_LB_API_KEY"`: OpenAI authentication keeps the Codex app's ChatGPT capabilities visible, and `env_key` supplies the Codex-LB bearer used by proxy routes. Normal conversations and Live Voice use the same registered Key contract. +The existing registered-Key profile remains available. It combines `requires_openai_auth = true` with `env_key = "CODEX_LB_API_KEY"`: OpenAI authentication keeps the Codex app's ChatGPT capabilities visible, and `env_key` supplies the Codex-LB bearer used by proxy routes. ## Operator example 1. An operator imports several upstream ChatGPT accounts through existing Codex-LB flows. -2. Under Settings → Live Voice, the operator enables OAuth Live Voice and selects the shared upstream Accounts allowed to serve OAuth calls. +2. Under Settings → Live Voice, the operator enables OAuth Live Voice and selects the shared upstream Accounts allowed to serve keyless calls. 3. Official Codex keeps `model_provider = "openai"` and routes WebRTC call creation to `http://127.0.0.1:2455/backend-api/codex` and sideband to `http://127.0.0.1:2455/v1`. -4. Codex-LB verifies the OAuth principal independently from the imported pool, selects within the configured set, binds the final call owner, and attaches sideband to that owner. -5. Registered Proxy API Key clients keep their existing assignments, limits, logs, affinity digests, and `requires_openai_auth = true` plus `env_key` Codex profile. +4. Codex-LB applies the existing zero-key origin check, derives a private credential-pair affinity digest, selects within the configured pool, binds the final call owner, and attaches sideband to that owner. +5. Registered Proxy API Key clients keep their existing assignments, limits, logs, affinity digests, and Key profile. ## Operational boundary -The policy controls access to pooled upstream accounts and stores no OAuth credentials. Cross-machine authorization-row handling remains outside this feature. `refresh_token_reused` remains an operator-managed recovery event. Logs and acceptance evidence use only routes, status, counts, hashes, and presence booleans. +Loopback needs no configuration. `proxy_unauthenticated_client_cidrs` remains an existing advanced opt-in for explicitly trusted raw socket peers. Other remote callers use registered Proxy API Keys. + +The policy stores no OAuth credentials. Cross-machine authorization-row handling remains outside this feature. `refresh_token_reused` remains an operator-managed recovery event. Logs and acceptance evidence use only routes, status, counts, hashes, and presence booleans. diff --git a/openspec/changes/add-oauth-live-voice-auth/design.md b/openspec/changes/add-oauth-live-voice-auth/design.md index b60a9358fc..5354d81c5a 100644 --- a/openspec/changes/add-oauth-live-voice-auth/design.md +++ b/openspec/changes/add-oauth-live-voice-auth/design.md @@ -2,42 +2,52 @@ Live Voice has two authenticated legs: HTTP call creation and a control sideband WebSocket. The serving upstream account must remain identical across both legs. Official Codex supplies one ChatGPT OAuth bearer plus `chatgpt-account-id`; Key-based clients supply a registered Codex-LB Proxy API Key. +Ordinary proxy traffic already has a zero-key admission contract. When global API-key authentication is disabled, requests are accepted only from loopback or an explicitly configured raw-socket peer CIDR. Proxy-header projection preserves the raw peer and resolves trusted proxy chains fail closed. + ## Goals - Preserve `model_provider = "openai"` and official OAuth. -- Preserve the existing registered-Key Codex profile and all Key authorization semantics. -- Support OAuth callers that have no corresponding imported Account row. -- Apply one operator-managed upstream pool to every verified OAuth caller. -- Preserve Key behavior and exact affinity input. -- Keep ownership immutable, private, bounded, and fail closed. +- Reuse the ordinary proxy's existing zero-key network boundary. +- Preserve the registered-Key Codex profile and every Key authorization semantic. +- Apply one operator-managed upstream pool to admitted keyless Live callers. +- Preserve exact-owner affinity across call creation and sideband. +- Add no setting, secret, or caller-registration entity. ## Decisions ### Classify bearer before authorization -`sk-clb-` bearers use strict Proxy Key validation. Other bearers require `chatgpt-account-id` and enter OAuth verification. Both call-create and all three sideband routes share this resolver. +`sk-clb-` bearers use strict Proxy Key validation. Other bearers require `chatgpt-account-id` and enter the keyless OAuth lane. Both call-create and all three sideband routes share this resolver. + +### Reuse zero-key origin admission + +The keyless lane calls the same proxy authorization dependency with no Proxy Key. Global API-key mode therefore fails closed. Disabled mode retains the existing loopback, trusted-proxy consensus, raw-socket capture, and `proxy_unauthenticated_client_cidrs` checks. + +The network boundary supplies trust. The OAuth credential pair supplies call ownership and separation between admitted clients. + +### Derive credential-safe affinity locally -### Separate caller identity from serving accounts +A purpose-specific HMAC key is derived from the existing persistent encryption key. The caller scope is: -The OAuth resolver validates the supplied credential pair against the upstream usage endpoint. Every caller with a stable verified `chatgpt_user_id` or `sub` receives `principal:{stable_seat_claim}` whether or not an imported Account matches. A matching imported Account remains optional `caller_account_id` metadata for usage and route integration. Credentials without a stable claim fall back to one unambiguous imported Account id. External callers follow the configured default upstream proxy route when routing is enabled. +`oauth-local:HMAC-SHA256(derived-key, bearer + NUL + normalized-chatgpt-account-id)` -The typed result contains `principal_id`, optional `caller_account_id` for usage integration, normalized ChatGPT account id, verified usage payload, and route. Raw credentials remain absent from logs and persistence. OAuth Live accepts independent verified principals through its global policy, while `/api/codex/usage` requires an eligible imported `caller_account_id` before exposing aggregate local pool usage. +Only this digest participates in the existing call-owner digest. Raw bearers, account headers, call ids, SDP, attestation values, frames, audio, and transcripts remain outside persistence and diagnostics. Replicas sharing the existing encryption key derive the same scope without another setting. -Identity validation admits at most 32 distinct in-flight credential pairs per process. Same-pair callers share one task. The final departing waiter cancels unfinished work, and cancelling work retains its admission slot until the task drains. +A refreshed bearer produces a new caller scope. A sideband using a different bearer cannot attach an older call; the client creates a new call after credential rotation. This preserves possession-based ownership without an upstream identity request. ### Use one global policy `oauth_live_global_policy` contains singleton id `1`, active state, and timestamps. `oauth_live_global_policy_accounts` links it to imported serving Accounts. Dashboard writes replace the complete set transactionally. Active policy writes require a non-empty known set; runtime lookup filters to currently active Accounts. -All verified OAuth principals share this pool. Each principal still receives isolated affinity material, so one principal cannot attach another principal's call after learning its call id. +Every admitted keyless caller shares this pool. Each credential pair receives isolated affinity material, so learning another call id does not grant sideband attachment. -### Keep Key and OAuth lanes compatible +### Keep Key and keyless lanes compatible -Key callers keep `api_key.id` affinity, assignments, limits, reservations, last-used updates, and request-log attribution. Their Codex profile continues to use `requires_openai_auth = true` for app-visible ChatGPT capabilities and `env_key` for the registered Codex-LB bearer. OAuth callers use `oauth:{principal_id}`, select within the global pool, and write request logs with `api_key_id = NULL`. +Key callers keep `api_key.id` affinity, assignments, limits, reservations, last-used updates, and request-log attribution. Keyless callers select within the global pool and write request logs with `api_key_id = NULL`. ### Keep the Settings job singular -The Live Voice card answers one operator question: which upstream Accounts may serve verified OAuth Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. Caller inputs and caller-to-account matching are absent from the UI. The compact selector keeps selected unavailable Accounts visible so operators can identify and remove stale assignments while other unavailable Accounts remain excluded. +The Live Voice card answers one operator question: which upstream Accounts may serve locally admitted keyless Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. The compact selector keeps selected unavailable Accounts visible so operators can identify and remove stale assignments while other unavailable Accounts remain excluded. ## Migration and rollback @@ -45,7 +55,7 @@ One revision creates the global singleton policy table and its allowed-account r ## Risks -- OAuth validation adds an upstream usage call on cache miss; bounded caching and singleflight limit fan-out. -- External principals require a stable verified seat claim, preserving cross-refresh ownership. +- Every process admitted by the existing zero-key network boundary can use the configured OAuth Live pool, matching ordinary zero-key proxy access. +- Bearer rotation changes affinity and requires a new Live call. +- Incorrect trusted-proxy configuration can alter locality decisions, so the existing raw-peer and forwarded-header regression suite remains part of acceptance. - Experimental client route keys can drift, so each bundled Codex upgrade requires call-create, sideband, and audible Live E2E acceptance. -- Client profiles can appear healthy while only one product path works, so acceptance covers normal conversation and audible Live Voice for both OAuth and registered-Key modes. diff --git a/openspec/changes/add-oauth-live-voice-auth/proposal.md b/openspec/changes/add-oauth-live-voice-auth/proposal.md index ca374e684b..0543398555 100644 --- a/openspec/changes/add-oauth-live-voice-auth/proposal.md +++ b/openspec/changes/add-oauth-live-voice-auth/proposal.md @@ -1,12 +1,13 @@ ## Why -Official Codex uses ChatGPT OAuth for both WebRTC call creation and the sideband WebSocket when the built-in `openai` provider is selected. That provider has no slot for a second Codex-LB Proxy API Key. Codex-LB needs an OAuth caller lane alongside its existing registered-Key lane so both supported client profiles can create and control Live Voice calls. +Official Codex uses ChatGPT OAuth for both WebRTC call creation and the sideband WebSocket when the built-in `openai` provider is selected. That provider has no slot for a second Codex-LB Proxy API Key. Codex-LB needs a local keyless caller lane alongside its existing registered-Key lane so both supported client profiles can create and control Live Voice calls. ## What Changes -- Accept registered Proxy API Keys and verified ChatGPT OAuth principals on the four private Live Voice routes. -- Dispatch `sk-clb-` bearers through existing strict Key validation and other bearers through verified ChatGPT OAuth identity resolution. -- Validate OAuth credentials upstream, derive a stable principal independently from imported serving accounts, and retain bounded cache/singleflight behavior. +- Accept registered Proxy API Keys and locally admitted ChatGPT OAuth credentials on the four private Live Voice routes. +- Dispatch `sk-clb-` bearers through existing strict Key validation. +- Admit other bearers only through the existing zero-key proxy origin contract: loopback or an explicitly configured raw-socket CIDR while global API-key authentication is disabled. +- Derive credential-safe caller affinity from a purpose-separated HMAC of the bearer and normalized `chatgpt-account-id`, using the existing persistent encryption key. - Add one global OAuth Live policy with an explicit upstream account pool. - Preserve Key assignments, limits, attribution, affinity input, and the documented `requires_openai_auth = true` plus `env_key` Codex profile. - Add one compact Settings editor: global enable switch, shared upstream pool, and save action. @@ -15,11 +16,10 @@ Official Codex uses ChatGPT OAuth for both WebRTC call creation and the sideband ## Modified Capabilities -- `realtime-api-compat`: dual Key/OAuth caller authentication and exact-owner sideband. -- `account-identity`: verified OAuth principals can exist independently from imported accounts. +- `realtime-api-compat`: dual registered-Key/local-keyless caller admission and exact-owner sideband. - `database-migrations`: global singleton policy and allowed-account relationship. - `frontend-architecture`: one Settings-level global policy editor. ## Impact -- OAuth identity resolution, realtime caller scope, policy persistence/API, Settings UI, one reversible migration, tests, and user documentation. +Realtime caller scope, policy persistence/API, Settings UI, one reversible migration, tests, and user documentation. diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md deleted file mode 100644 index 7b9558e502..0000000000 --- a/openspec/changes/add-oauth-live-voice-auth/specs/account-identity/spec.md +++ /dev/null @@ -1,61 +0,0 @@ -## ADDED Requirements - -### Requirement: Verified OAuth Live callers resolve to a stable principal - -The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. That principal SHALL remain unchanged when a matching imported Account is added, removed, paused, or otherwise becomes ineligible. A matching imported Account MAY remain attached separately as caller integration metadata for usage and route selection. A credential without a stable claim SHALL require one unambiguous imported Account and MAY use that Account id as its fallback principal. - -#### Scenario: External OAuth caller is accepted - -- **GIVEN** valid OAuth credentials carry a stable seat claim and no imported Account matches them -- **WHEN** the caller reaches a Live Voice route -- **THEN** upstream credential validation succeeds through the configured default route -- **AND** the caller receives an independent stable principal - -#### Scenario: Imported Account lifecycle preserves caller affinity - -- **GIVEN** verified OAuth credentials carry a stable seat claim -- **WHEN** a matching imported Account is added, removed, paused, or otherwise becomes ineligible -- **THEN** the principal remains derived from the same stable seat claim -- **AND** caller Account metadata may change independently without changing the Live ownership scope - -#### Scenario: Identity remains ambiguous - -- **WHEN** credentials expose no stable seat claim and cannot resolve one eligible imported seat -- **THEN** identity resolution fails before policy lookup and upstream account selection -- **AND** the response reveals no identity or candidate details - -### Requirement: OAuth identity validation is bounded and coalesced - -Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Each process MUST admit at most 32 distinct in-flight credential validations; excess distinct misses MUST fail with a typed rate-limit response before database or upstream work begins. When the final waiter for an unfinished validation disconnects, the validation task MUST be cancelled and MUST continue consuming one admission slot until cancellation has drained. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. - -#### Scenario: Concurrent validation misses are coalesced - -- **GIVEN** the same uncached bearer and normalized `chatgpt-account-id` reach OAuth identity validation concurrently -- **WHEN** upstream validation is still in flight -- **THEN** all callers await one shared validation -- **AND** cache keys and diagnostics expose no raw credential - -#### Scenario: Distinct validation capacity is exhausted - -- **GIVEN** 32 distinct credential validations remain in flight on one process -- **WHEN** another uncached credential pair requests validation -- **THEN** the request fails with a typed rate-limit response -- **AND** no database or upstream validation begins for that pair - -#### Scenario: The final waiter disconnects - -- **GIVEN** one unfinished validation has no remaining request waiter -- **WHEN** its final waiter disconnects -- **THEN** the process cancels and drains the validation task -- **AND** the admission slot is released only after the task completes cancellation - -### Requirement: Aggregate Codex usage requires imported Account membership - -The OAuth-authenticated `/api/codex/usage` path SHALL authorize only a verified identity that resolves to one currently eligible imported Account. An independently verified external OAuth principal MAY use enabled OAuth Live routes but SHALL NOT receive the operator's aggregate local account-pool usage payload. Registered Proxy API Keys SHALL retain their existing usage behavior. - -#### Scenario: External OAuth principal requests aggregate usage - -- **GIVEN** valid OAuth credentials resolve to a stable principal without an eligible imported Account -- **WHEN** the caller requests `/api/codex/usage` -- **THEN** authorization fails before aggregate usage is read -- **AND** the same principal remains eligible for OAuth Live when the global policy permits it diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md index 5eef5de59d..d4feb62964 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/frontend-architecture/spec.md @@ -9,7 +9,7 @@ The Settings page SHALL expose one Live Voice card with a global OAuth enable sw - **GIVEN** one or more selectable upstream Accounts - **WHEN** a dashboard writer enables OAuth Live, selects Accounts, and saves - **THEN** the app replaces the global policy atomically -- **AND** every verified OAuth principal uses that pool for subsequent call creation +- **AND** every locally admitted keyless OAuth caller uses that pool for subsequent call creation #### Scenario: Operator revokes global access diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md index 90e07090d0..a5bedc03f8 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md @@ -2,7 +2,7 @@ ### Requirement: Call creation binds the final account under an authenticated caller scope -`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a verified OAuth principal. A `sk-clb-` bearer SHALL use strict Key validation. OAuth SHALL require an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the authenticated caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. OAuth callers use the same digest formula with `oauth:{principal_id}` as scope material. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a normalized `chatgpt-account-id` and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, bearer + NUL + normalized-account-id)` as scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. #### Scenario: initial or replacement account creates the call @@ -24,13 +24,19 @@ - **THEN** the proxy rejects the request before selecting or contacting an upstream account - **AND** it does not create an anonymous ownership namespace -#### Scenario: verified OAuth principal creates a call +#### Scenario: local keyless OAuth caller creates a call -- **GIVEN** verified OAuth credentials and an active global policy with eligible serving Accounts +- **GIVEN** global API-key authentication is disabled, the request passes the existing zero-key origin guard, and the global policy has eligible serving Accounts - **WHEN** call creation succeeds -- **THEN** the final serving Account binds under the OAuth principal +- **THEN** the final serving Account binds under the credential-pair HMAC scope - **AND** request logging uses nullable API-key attribution +#### Scenario: remote OAuth caller is outside the zero-key boundary + +- **WHEN** a non-Key bearer arrives from a connection that fails the existing zero-key origin guard, or while global API-key authentication is enabled +- **THEN** the route returns `401 invalid_api_key` before policy lookup or account selection +- **AND** projected loopback, Host, and forwarded-header hints do not override the preserved raw-socket and trusted-proxy checks + #### Scenario: registered Key remains compatible with Codex conversations - **GIVEN** a Codex provider uses `requires_openai_auth = true` and a registered Key through `env_key` @@ -68,7 +74,7 @@ ### Requirement: Every sideband route uses the exact bound owner without refresh or failover -The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and verified OAuth principals. All three routes SHALL use the same caller resolver as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. OAuth sideband SHALL resolve ownership with `principal_id` and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the credential-pair HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. #### Scenario: returned Location joins through every supported ingress @@ -96,6 +102,12 @@ The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id} - **THEN** attachment fails closed without revealing or substituting the owner - **AND** it neither refreshes credentials nor selects another account +#### Scenario: keyless credential changes during a call + +- **WHEN** sideband supplies a different bearer or normalized `chatgpt-account-id` from call creation +- **THEN** it resolves a different ownership digest and returns the credential-safe not-found response +- **AND** the client creates a new call after OAuth credential rotation + #### Scenario: refreshed call owner attaches with current identity - **GIVEN** call creation refreshed and persisted the final owner's token or identity while routing inputs remained cached @@ -144,7 +156,7 @@ Documentation SHALL present two complete Codex client profiles. The built-in OAu - **WHEN** an operator follows the built-in OAuth profile or the registered-Key profile - **THEN** ordinary conversations use the selected provider contract - **AND** call creation and sideband both route through codex-lb -- **AND** the OAuth Live policy controls only verified OAuth callers +- **AND** the OAuth Live policy controls only callers admitted through the existing zero-key origin boundary ## RENAMED Requirements diff --git a/openspec/changes/add-oauth-live-voice-auth/tasks.md b/openspec/changes/add-oauth-live-voice-auth/tasks.md index f97ffba438..0377d40a0c 100644 --- a/openspec/changes/add-oauth-live-voice-auth/tasks.md +++ b/openspec/changes/add-oauth-live-voice-auth/tasks.md @@ -1,18 +1,18 @@ ## 1. Contract -- [x] 1.1 Define verified OAuth identity, global policy, dual-caller Live routing, exact-owner sideband, persistence, and Settings requirements. +- [x] 1.1 Define local keyless OAuth admission, global policy, dual-caller Live routing, exact-owner sideband, persistence, and Settings requirements. - [x] 1.2 Document the complete built-in OAuth and registered-Key Codex profiles, including both experimental realtime route overrides. ## 2. Implementation -- [x] 2.1 Add the typed OAuth identity resolver with bounded caching, singleflight, expiry limits, and credential-safe errors. +- [x] 2.1 Reuse the existing zero-key origin guard and derive credential-safe local affinity from the existing persistent encryption key. - [x] 2.2 Add the global policy ORM, reversible migration, repository, service, schemas, Dashboard API, and audit event. - [x] 2.3 Add the Settings Live Voice card with a global enable switch, upstream Account multi-select, translations, and validation. -- [x] 2.4 Add one HTTP/WS caller resolver that preserves Proxy Key behavior and authorizes OAuth callers through the global pool. +- [x] 2.4 Add one HTTP/WS caller resolver that preserves Proxy Key behavior and authorizes local keyless callers through the global pool. - [x] 2.5 Preserve exact-owner binding across call creation and all supported sideband routes. ## 3. Verification and delivery -- [x] 3.1 Cover identity caching, policy validation, migration round-trip, HTTP/three-WS auth, owner continuity, nullable logging, and Key compatibility. -- [x] 3.2 Pass real local Codex Desktop normal conversation and audible Live Voice with both official OAuth and a registered Proxy API Key, plus policy revoke and logging/privacy acceptance. +- [x] 3.1 Cover origin admission, credential affinity, policy validation, migration round-trip, HTTP/three-WS auth, owner continuity, nullable logging, and Key compatibility. +- [x] 3.2 Pass real local Codex Desktop normal conversation and audible Live Voice with official OAuth, plus registered-Key regression acceptance. - [x] 3.3 Sync stable requirements to main specs/context and pass the local verification gates. diff --git a/openspec/specs/account-identity/spec.md b/openspec/specs/account-identity/spec.md index 97a8124ace..cb63ef1ef2 100644 --- a/openspec/specs/account-identity/spec.md +++ b/openspec/specs/account-identity/spec.md @@ -22,62 +22,3 @@ Dashboard account summaries MUST expose and render the upstream ChatGPT account - **THEN** it displays the ChatGPT account id - **AND** it does not display the generic unknown-workspace label -### Requirement: Verified OAuth Live callers resolve to a stable principal - -The system SHALL validate a supplied ChatGPT bearer and `chatgpt-account-id` against the upstream usage endpoint before trusting identity claims. It SHALL derive a stable principal from verified per-seat claims. That principal SHALL remain unchanged when a matching imported Account is added, removed, paused, or otherwise becomes ineligible. A matching imported Account MAY remain attached separately as caller integration metadata for usage and route selection. A credential without a stable claim SHALL require one unambiguous imported Account and MAY use that Account id as its fallback principal. - -#### Scenario: External OAuth caller is accepted - -- **GIVEN** valid OAuth credentials carry a stable seat claim and no imported Account matches them -- **WHEN** the caller reaches a Live Voice route -- **THEN** upstream credential validation succeeds through the configured default route -- **AND** the caller receives an independent stable principal - -#### Scenario: Imported Account lifecycle preserves caller affinity - -- **GIVEN** verified OAuth credentials carry a stable seat claim -- **WHEN** a matching imported Account is added, removed, paused, or otherwise becomes ineligible -- **THEN** the principal remains derived from the same stable seat claim -- **AND** caller Account metadata may change independently without changing the Live ownership scope - -#### Scenario: Identity remains ambiguous - -- **WHEN** credentials expose no stable seat claim and cannot resolve one eligible imported seat -- **THEN** identity resolution fails before policy lookup and upstream account selection -- **AND** the response reveals no identity or candidate details - -### Requirement: OAuth identity validation is bounded and coalesced - -Cache and singleflight keys MUST be a one-way digest over bearer and normalized `chatgpt-account-id`. Concurrent misses for the same pair MUST share one upstream validation. Each process MUST admit at most 32 distinct in-flight credential validations; excess distinct misses MUST fail with a typed rate-limit response before database or upstream work begins. When the final waiter for an unfinished validation disconnects, the validation task MUST be cancelled and MUST continue consuming one admission slot until cancellation has drained. Positive entries MUST expire within 60 seconds and token expiry; credential-denial entries MUST expire within 5 seconds. Upstream availability and rate-limit failures MUST preserve typed failure semantics. - -#### Scenario: Concurrent validation misses are coalesced - -- **GIVEN** the same uncached bearer and normalized `chatgpt-account-id` reach OAuth identity validation concurrently -- **WHEN** upstream validation is still in flight -- **THEN** all callers await one shared validation -- **AND** cache keys and diagnostics expose no raw credential - -#### Scenario: Distinct validation capacity is exhausted - -- **GIVEN** 32 distinct credential validations remain in flight on one process -- **WHEN** another uncached credential pair requests validation -- **THEN** the request fails with a typed rate-limit response -- **AND** no database or upstream validation begins for that pair - -#### Scenario: The final waiter disconnects - -- **GIVEN** one unfinished validation has no remaining request waiter -- **WHEN** its final waiter disconnects -- **THEN** the process cancels and drains the validation task -- **AND** the admission slot is released only after the task completes cancellation - -### Requirement: Aggregate Codex usage requires imported Account membership - -The OAuth-authenticated `/api/codex/usage` path SHALL authorize only a verified identity that resolves to one currently eligible imported Account. An independently verified external OAuth principal MAY use enabled OAuth Live routes but SHALL NOT receive the operator's aggregate local account-pool usage payload. Registered Proxy API Keys SHALL retain their existing usage behavior. - -#### Scenario: External OAuth principal requests aggregate usage - -- **GIVEN** valid OAuth credentials resolve to a stable principal without an eligible imported Account -- **WHEN** the caller requests `/api/codex/usage` -- **THEN** authorization fails before aggregate usage is read -- **AND** the same principal remains eligible for OAuth Live when the global policy permits it diff --git a/openspec/specs/frontend-architecture/spec.md b/openspec/specs/frontend-architecture/spec.md index 9dd1c4ffd7..f1b58606d8 100644 --- a/openspec/specs/frontend-architecture/spec.md +++ b/openspec/specs/frontend-architecture/spec.md @@ -2393,7 +2393,7 @@ The Settings page SHALL expose one Live Voice card with a global OAuth enable sw - **GIVEN** one or more selectable upstream Accounts - **WHEN** a dashboard writer enables OAuth Live, selects Accounts, and saves - **THEN** the app replaces the global policy atomically -- **AND** every verified OAuth principal uses that pool for subsequent call creation +- **AND** every locally admitted keyless OAuth caller uses that pool for subsequent call creation #### Scenario: Operator revokes global access diff --git a/openspec/specs/realtime-api-compat/context.md b/openspec/specs/realtime-api-compat/context.md index c0ddcd9251..fe729522ef 100644 --- a/openspec/specs/realtime-api-compat/context.md +++ b/openspec/specs/realtime-api-compat/context.md @@ -10,35 +10,38 @@ See `openspec/specs/realtime-api-compat/spec.md` for normative requirements and | Profile | Client authorization | Serving-account scope | | --- | --- | --- | -| Built-in `openai` | Official ChatGPT OAuth bearer plus `chatgpt-account-id` | Settings → Live Voice global OAuth pool | +| Built-in `openai` | Locally admitted official ChatGPT OAuth bearer plus `chatgpt-account-id` | Settings → Live Voice global OAuth pool | | Registered Key | `sk-clb-` bearer from `env_key`; Codex keeps `requires_openai_auth = true` | Existing Key assignments and limits | -The built-in profile preserves the official OpenAI provider and requires no client-side Codex-LB Key. The registered-Key profile preserves the established codex-lb contract for conversations and Live Voice. Both profiles route call creation and sideband through codex-lb. +The built-in profile preserves the official OpenAI provider and requires no client-side Codex-LB Key. It inherits the ordinary proxy's zero-key origin boundary. The registered-Key profile preserves the established codex-lb contract. Both profiles route call creation and sideband through codex-lb. ## Rationale and Decisions -- **Authentication dispatch is explicit:** `sk-clb-` bearers enter strict Proxy Key validation. Other bearers require verified ChatGPT OAuth identity. -- **Caller identity and serving accounts are separate:** OAuth validation derives a stable principal from verified claims. The global policy selects which imported Accounts may serve that principal. -- **Possession is caller-scoped:** A call id alone grants no access. The caller scope participates in the ownership digest, isolating Keys and OAuth principals from each other. -- **Final success owns the call:** The generic Codex control request may refresh or fail over before returning a response. Ownership is captured after the final successful account returns a supported call `Location`. -- **Ownership is durable and opaque:** The sticky-session store holds a bounded digest and owner reference in a reserved namespace. Raw call ids, credentials, SDP, attestation values, and frames remain outside persistence. -- **Attachment enforces hard continuity:** Every ingress resolves the exact owner, rechecks current caller scope and account state, loads current persisted identity, and acquires one stream lease. +- **Bearer dispatch is explicit:** `sk-clb-` bearers enter strict Proxy Key validation. Every other bearer enters the keyless lane. +- **Zero-key origin admission is shared:** Keyless Live calls reuse the ordinary proxy's loopback, trusted-proxy consensus, preserved raw-socket peer, and existing unauthenticated CIDR checks. Global API-key mode closes this lane. +- **Possession is caller-scoped:** A purpose-separated HMAC of the bearer and normalized account header participates in the ownership digest. A call id alone grants no access. +- **Final success owns the call:** Ownership is captured after the final successful account returns a supported call `Location`. +- **Ownership is durable and opaque:** The sticky-session store holds a bounded digest and owner reference. Raw call ids, credentials, SDP, attestation values, and frames remain outside persistence. +- **Attachment enforces hard continuity:** Every ingress recomputes caller scope, resolves the exact owner, rechecks policy and account state, loads current persisted upstream identity, and acquires one stream lease. - **Protocols stay explicit:** Current-app and v3 ingress connect to `/v1/live/{call_id}`. Legacy ingress preserves remaining ordered query fields and appends one normalized `call_id` to `/v1/realtime`. - **Base setup remains zero-config:** OAuth Live starts disabled. Registered-Key Live keeps its existing configuration and behavior. ## Constraints - All ids, mappings, batches, waits, messages, close reasons, and cleanup work are bounded. -- Missing or invalid caller credentials fail before account selection. +- Missing credentials and connections outside the zero-key boundary fail before policy lookup and account selection. - OAuth policy lookup returns only active imported Accounts from its explicit pool. -- SDP, audio, transcripts, attestation values, frame bodies, tokens, and raw call ids remain absent from persistence and diagnostics. +- The HMAC key is derived from the existing persistent encryption key with a dedicated domain label; no new secret or setting is introduced. +- SDP, audio, transcripts, attestation values, frame bodies, tokens, account headers, and raw call ids remain absent from persistence and diagnostics. - The feature adds one reversible migration and one Settings card. It adds no environment setting, dependency, dashboard navigation item, README section, `.env.example` entry, background scheduler, public model, or public Realtime endpoint. - The connector preserves client-offered WebSocket subprotocol order and returns only an upstream-selected offered value. - Reserved ownership stays hidden from ordinary sticky-session list and delete operations. ## Failure Modes -- **OAuth policy inactive or empty:** Deny OAuth Live before account selection with `403 oauth_live_not_enabled`. +- **Source outside the zero-key boundary or global API-key mode enabled:** Deny the keyless lane with `401 invalid_api_key`. +- **OAuth policy inactive or empty:** Deny keyless Live before account selection with `403 oauth_live_not_enabled`. +- **Bearer, account header, or encryption key changed:** The ownership namespace changes; an existing sideband receives the credential-safe not-found response and the client creates a new call. - **Missing or unsupported successful `Location` or durable binding failure:** Persist one private error request row and return `503 realtime_call_binding_failed`. - **Conflicting immutable owner:** Preserve the original owner and fail closed. - **Expired ownership or owner outside the current caller scope:** Deny attachment without account substitution. @@ -49,17 +52,17 @@ The built-in profile preserves the official OpenAI provider and requires no clie ### Built-in OAuth profile -1. Codex uses `model_provider = "openai"` and sends official ChatGPT OAuth credentials. -2. codex-lb validates a stable OAuth principal and loads the active global OAuth pool. -3. The final serving Account owns the new call under an OAuth-principal digest. -4. Sideband revalidates the principal and reconnects to that exact owner. +1. Codex uses `model_provider = "openai"` and sends official ChatGPT OAuth credentials from loopback. +2. codex-lb applies its existing zero-key origin guard and derives an opaque credential-pair scope locally. +3. The final serving Account owns the new call under that scope. +4. Sideband presents the same credential pair and reconnects to that exact owner. ### Registered-Key profile -1. Codex uses a custom provider named `openai`, `requires_openai_auth = true`, and `env_key = "CODEX_LB_API_KEY"`. +1. Codex uses a custom provider, `requires_openai_auth = true`, and `env_key = "CODEX_LB_API_KEY"`. 2. The registered Key keeps its existing account assignments, limits, request attribution, and affinity input. -3. Normal conversations and Live Voice calls traverse the same Key-authenticated proxy contract. +3. Normal conversations and Live Voice calls traverse the Key-authenticated proxy contract. ## Operational Notes -Both realtime base URLs must point at codex-lb. Each bundled Codex upgrade receives a call-create route probe, sideband route probe, normal-conversation check, and audible Live Voice check. A revoked OAuth policy affects subsequent OAuth authorization; registered-Key traffic continues under its existing Key policy. +Both realtime base URLs must point at codex-lb. Each bundled Codex upgrade receives a call-create route probe, sideband route probe, normal-conversation check, and audible Live Voice check. Loopback requires no CIDR configuration. The existing unauthenticated raw-peer CIDR setting remains an advanced operator opt-in. diff --git a/openspec/specs/realtime-api-compat/spec.md b/openspec/specs/realtime-api-compat/spec.md index 6f93d0494f..76b97097b2 100644 --- a/openspec/specs/realtime-api-compat/spec.md +++ b/openspec/specs/realtime-api-compat/spec.md @@ -8,7 +8,7 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro ### Requirement: Call creation binds the final account under an authenticated caller scope -`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a verified OAuth principal. A `sk-clb-` bearer SHALL use strict Key validation. OAuth SHALL require an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the authenticated caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. OAuth callers use the same digest formula with `oauth:{principal_id}` as scope material. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a normalized `chatgpt-account-id` and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, bearer + NUL + normalized-account-id)` as scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. #### Scenario: initial or replacement account creates the call @@ -30,13 +30,19 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro - **THEN** the proxy rejects the request before selecting or contacting an upstream account - **AND** it does not create an anonymous ownership namespace -#### Scenario: verified OAuth principal creates a call +#### Scenario: local keyless OAuth caller creates a call -- **GIVEN** verified OAuth credentials and an active global policy with eligible serving Accounts +- **GIVEN** global API-key authentication is disabled, the request passes the existing zero-key origin guard, and the global policy has eligible serving Accounts - **WHEN** call creation succeeds -- **THEN** the final serving Account binds under the OAuth principal +- **THEN** the final serving Account binds under the credential-pair HMAC scope - **AND** request logging uses nullable API-key attribution +#### Scenario: remote OAuth caller is outside the zero-key boundary + +- **WHEN** a non-Key bearer arrives from a connection that fails the existing zero-key origin guard, or while global API-key authentication is enabled +- **THEN** the route returns `401 invalid_api_key` before policy lookup or account selection +- **AND** projected loopback, Host, and forwarded-header hints do not override the preserved raw-socket and trusted-proxy checks + #### Scenario: registered Key remains compatible with Codex conversations - **GIVEN** a Codex provider uses `requires_openai_auth = true` and a registered Key through `env_key` @@ -74,7 +80,7 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro ### Requirement: Every sideband route uses the exact bound owner without refresh or failover -The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and verified OAuth principals. All three routes SHALL use the same caller resolver as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. OAuth sideband SHALL resolve ownership with `principal_id` and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the credential-pair HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. #### Scenario: returned Location joins through every supported ingress @@ -102,6 +108,12 @@ The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id} - **THEN** attachment fails closed without revealing or substituting the owner - **AND** it neither refreshes credentials nor selects another account +#### Scenario: keyless credential changes during a call + +- **WHEN** sideband supplies a different bearer or normalized `chatgpt-account-id` from call creation +- **THEN** it resolves a different ownership digest and returns the credential-safe not-found response +- **AND** the client creates a new call after OAuth credential rotation + #### Scenario: refreshed call owner attaches with current identity - **GIVEN** call creation refreshed and persisted the final owner's token or identity while routing inputs remained cached @@ -184,4 +196,4 @@ Documentation SHALL present two complete Codex client profiles. The built-in OAu - **WHEN** an operator follows the built-in OAuth profile or the registered-Key profile - **THEN** ordinary conversations use the selected provider contract - **AND** call creation and sideband both route through codex-lb -- **AND** the OAuth Live policy controls only verified OAuth callers +- **AND** the OAuth Live policy controls only callers admitted through the existing zero-key origin boundary diff --git a/tests/integration/test_accounts_repository.py b/tests/integration/test_accounts_repository.py index 2c12d9b004..81d9b3ce0d 100644 --- a/tests/integration/test_accounts_repository.py +++ b/tests/integration/test_accounts_repository.py @@ -258,38 +258,3 @@ async def test_upsert_account_slot_adds_third_label_only_workspace_for_same_emai ("triton_workspace", "Triton"), ("atlas_workspace", "Atlas"), ] - - -@pytest.mark.asyncio -async def test_oauth_identity_candidates_include_limited_accounts_and_exclude_hard_blocked(db_setup): - del db_setup - shared_chatgpt_id = "chatgpt_oauth_live_candidates" - statuses = { - "active": AccountStatus.ACTIVE, - "rate_limited": AccountStatus.RATE_LIMITED, - "quota_exceeded": AccountStatus.QUOTA_EXCEEDED, - "paused": AccountStatus.PAUSED, - "reauth_required": AccountStatus.REAUTH_REQUIRED, - "deactivated": AccountStatus.DEACTIVATED, - } - - async with SessionLocal() as session: - accounts = [] - for name, status in statuses.items(): - account = _account( - f"oauth_{name}", - chatgpt_account_id=shared_chatgpt_id, - email=f"{name}@example.com", - ) - account.status = status - accounts.append(account) - session.add_all(accounts) - await session.commit() - - candidates = await AccountsRepository(session).list_eligible_by_chatgpt_account_id(shared_chatgpt_id) - - assert [candidate.id for candidate in candidates] == [ - "oauth_active", - "oauth_quota_exceeded", - "oauth_rate_limited", - ] diff --git a/tests/integration/test_auth_middleware.py b/tests/integration/test_auth_middleware.py index 1fdc37ea00..de3448bbcb 100644 --- a/tests/integration/test_auth_middleware.py +++ b/tests/integration/test_auth_middleware.py @@ -1201,7 +1201,7 @@ async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: ob assert account_id == raw_chatgpt_account_id return UsagePayload.model_validate({"plan_type": "team"}) - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) await async_client.post("/api/dashboard-auth/logout", json={}) allowed = await async_client.get( @@ -1225,7 +1225,7 @@ async def test_codex_usage_blocks_unregistered_chatgpt_account_id(async_client, async def should_not_call_fetch_usage(**_: object) -> UsagePayload: raise AssertionError("fetch_usage should not be called for unknown chatgpt-account-id") - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", should_not_call_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", should_not_call_fetch_usage) await async_client.post("/api/dashboard-auth/logout", json={}) blocked = await async_client.get( diff --git a/tests/integration/test_codex_usage_api.py b/tests/integration/test_codex_usage_api.py index e674281b1b..95bf0593cf 100644 --- a/tests/integration/test_codex_usage_api.py +++ b/tests/integration/test_codex_usage_api.py @@ -1,12 +1,9 @@ from __future__ import annotations -import base64 -import json from datetime import timedelta, timezone import pytest -from app.core.auth.codex_oauth_identity import clear_codex_oauth_identity_cache from app.core.clients.rate_limit_reset_credits import RateLimitResetCreditsSnapshot, ResetCreditItem from app.core.clients.usage import ConsumeRateLimitResetCreditResponse from app.core.crypto import TokenEncryptor @@ -24,11 +21,6 @@ pytestmark = pytest.mark.integration -def _jwt(payload: dict[str, object]) -> str: - encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - return f"header.{encoded}.signature" - - def _make_account( account_id: str, email: str, @@ -83,38 +75,12 @@ async def _create_api_key(*, name: str, limits: list[LimitRuleInput] | None = No @pytest.fixture(autouse=True) def stub_codex_usage_caller_validation(monkeypatch): - clear_codex_oauth_identity_cache() - async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: object) -> UsagePayload: assert access_token == "chatgpt-token" assert account_id is not None return UsagePayload.model_validate({"plan_type": "plus"}) - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) - yield - clear_codex_oauth_identity_cache() - - -@pytest.mark.asyncio -async def test_codex_usage_rejects_external_verified_oauth_principal(async_client, db_setup, monkeypatch): - expected_access_token = _jwt({"sub": "external-user"}) - - async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: object) -> UsagePayload: - assert access_token == expected_access_token - return UsagePayload.model_validate({"plan_type": "plus", "workspace_id": account_id}) - - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) - - response = await async_client.get( - "/api/codex/usage", - headers={ - "Authorization": f"Bearer {expected_access_token}", - "chatgpt-account-id": "workspace_external", - }, - ) - - assert response.status_code == 401 - assert response.json()["error"]["code"] == "invalid_api_key" + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) @pytest.mark.asyncio @@ -756,7 +722,7 @@ async def stub_fetch_usage(*, access_token: str, account_id: str | None, **_: ob } ) - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) response = await async_client.get( "/api/codex/usage", @@ -805,7 +771,7 @@ async def force_refresh( refreshed_account_ids.append(f"{account.id}:{ignore_refresh_disabled}:{access_token_override}") return True - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", stub_consume_rate_limit_reset_credit) monkeypatch.setattr("app.modules.proxy.api.UsageUpdater", StubUsageUpdater) cache_generation = get_account_selection_cache().generation @@ -883,7 +849,7 @@ async def force_refresh( refreshed_account_ids.append(account.id) return True - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", stub_consume_rate_limit_reset_credit) monkeypatch.setattr("app.modules.proxy.api.UsageUpdater", StubUsageUpdater) @@ -928,7 +894,7 @@ async def stub_fetch_usage(**_: object) -> UsagePayload: async def should_not_consume(**_: object) -> ConsumeRateLimitResetCreditResponse: raise AssertionError("empty redeem_request_id should not be forwarded upstream") - monkeypatch.setattr("app.core.auth.codex_oauth_identity.fetch_usage", stub_fetch_usage) + monkeypatch.setattr("app.core.auth.dependencies.fetch_usage", stub_fetch_usage) monkeypatch.setattr("app.modules.proxy.api.consume_rate_limit_reset_credit", should_not_consume) response = await async_client.post( diff --git a/tests/integration/test_proxy_realtime_live.py b/tests/integration/test_proxy_realtime_live.py index 15eca9b3da..498ae21cb9 100644 --- a/tests/integration/test_proxy_realtime_live.py +++ b/tests/integration/test_proxy_realtime_live.py @@ -9,14 +9,17 @@ import pytest from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient from sqlalchemy import select from starlette.testclient import WebSocketDenialResponse from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.websockets import WebSocketDisconnect from uvicorn.protocols.utils import get_client_addr, get_path_with_query_string +import app.core.auth.dependencies as auth_dependencies import app.core.clients.proxy_websocket as proxy_websocket_module import app.modules.proxy.api as proxy_api_module +import app.modules.proxy.realtime_auth as realtime_auth_module import app.modules.proxy.service as proxy_module from app.core.auth import generate_unique_account_id from app.core.auth.dependencies import validate_required_proxy_api_key_authorization @@ -26,7 +29,7 @@ ProxyResponseError, ) from app.core.clients.proxy_websocket import UpstreamWebSocketMessage -from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError +from app.core.exceptions import ProxyAuthError from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute from app.db.models import RequestLog from app.db.session import SessionLocal @@ -223,13 +226,63 @@ async def fake_proxy_live( assert "quicksilver" not in access_messages[0] +def test_realtime_sideband_oauth_uses_local_keyless_scope( + app_instance, + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_scopes: list[RealtimeCallerScope] = [] + allowed_account_id = "allowed-local-account" + + async def load_policy() -> frozenset[str]: + return frozenset({allowed_account_id}) + + async def fake_proxy_live( + self, + websocket, + call_id, + headers, + query_params, + *, + protocol, + caller_scope, + client_ip=None, + ) -> None: + del self, call_id, headers, query_params, protocol, client_ip + captured_scopes.append(caller_scope) + await websocket.accept() + await websocket.send_text("ready") + await websocket.close(code=1000) + + monkeypatch.setattr(proxy_module.ProxyService, "proxy_realtime_live_websocket", fake_proxy_live) + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) + + with TestClient( + app_instance, + base_url="http://localhost", + client=("127.0.0.1", 50000), + ) as client: + with client.websocket_connect( + "ws://localhost/v1/live/rtc_local_oauth", + headers={ + "Authorization": "Bearer oauth-token", + "chatgpt-account-id": "workspace-caller", + }, + ) as websocket: + assert websocket.receive_text() == "ready" + + assert len(captured_scopes) == 1 + scope = captured_scopes[0] + assert scope.kind == "oauth" + assert scope.api_key is None + assert scope.allowed_account_ids == frozenset({allowed_account_id}) + assert scope.affinity_scope_material.startswith("oauth-local:") + + @pytest.mark.parametrize( ("error", "expected_status", "expected_code"), [ (ProxyAuthError("invalid caller"), 401, "invalid_api_key"), (OAuthLiveNotEnabledError(), 403, "oauth_live_not_enabled"), - (ProxyRateLimitError("validation limited"), 429, "rate_limit_exceeded"), - (ProxyUpstreamError("validation unavailable"), 503, "upstream_error"), ], ) def test_realtime_sideband_serializes_typed_caller_denials( @@ -291,16 +344,6 @@ async def test_realtime_call_create_oauth_scope_selects_only_allowed_account( async_client, monkeypatch: pytest.MonkeyPatch, ) -> None: - caller = await async_client.post( - "/api/accounts/import", - files={ - "auth_json": ( - "caller.json", - json.dumps(_auth_json("workspace-caller", "caller@example.com")), - "application/json", - ) - }, - ) allowed = await async_client.post( "/api/accounts/import", files={ @@ -311,17 +354,13 @@ async def test_realtime_call_create_oauth_scope_selects_only_allowed_account( ) }, ) - assert caller.status_code == 200 assert allowed.status_code == 200 - caller_account_id = caller.json()["accountId"] allowed_account_id = allowed.json()["accountId"] - scope = RealtimeCallerScope.for_oauth( - principal_id=caller_account_id, - allowed_account_ids={allowed_account_id}, + policy = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": True, "allowedAccountIds": [allowed_account_id]}, ) - - async def resolve_scope(*_args: object, **_kwargs: object) -> RealtimeCallerScope: - return scope + assert policy.status_code == 200 upstream_account_ids: list[str | None] = [] @@ -333,7 +372,10 @@ async def create_call(*_args: object, account_id: str | None, **_kwargs: object) headers={"location": "/v1/realtime/calls/rtc_oauth_allowed"}, ) - monkeypatch.setattr(proxy_api_module, "resolve_realtime_caller_scope", resolve_scope) + async def reject_usage_validation(*_args: object, **_kwargs: object) -> object: + raise AssertionError("Live admission must not call the OpenAI usage endpoint") + + monkeypatch.setattr(auth_dependencies, "fetch_usage", reject_usage_validation) monkeypatch.setattr(proxy_module, "core_codex_control_request", create_call) service = get_proxy_service_for_app(async_client._transport.app) original_write_request_log = service._write_request_log @@ -374,6 +416,35 @@ async def capture_request_log(**kwargs: object) -> None: assert persisted.api_key_id is None +@pytest.mark.asyncio +async def test_realtime_call_create_rejects_remote_oauth_before_account_selection( + async_client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fail_selection(*_args: object, **_kwargs: object) -> object: + raise AssertionError("remote OAuth must be denied before account selection") + + monkeypatch.setattr(proxy_module.ProxyService, "codex_control_request", fail_selection) + app = async_client._transport.app + transport = ASGITransport(app=app, client=("203.0.113.10", 50000)) + async with AsyncClient(transport=transport, base_url="http://lb.example") as remote_client: + response = await remote_client.post( + "/backend-api/codex/realtime/calls", + content=b"offer", + headers={ + "Authorization": "Bearer oauth-token", + "chatgpt-account-id": "workspace-caller", + }, + ) + + assert response.status_code == 401 + assert response.json()["error"] == { + "code": "invalid_api_key", + "message": "Proxy authentication must be configured before remote access is allowed", + "type": "authentication_error", + } + + @pytest.mark.parametrize( ("path", "logged_path", "call_id"), [ diff --git a/tests/integration/test_proxy_sticky_sessions.py b/tests/integration/test_proxy_sticky_sessions.py index 539bff0005..ec1c129e33 100644 --- a/tests/integration/test_proxy_sticky_sessions.py +++ b/tests/integration/test_proxy_sticky_sessions.py @@ -1747,13 +1747,13 @@ def test_realtime_call_affinity_key_preserves_registered_key_digest_bytes() -> N ) -def test_realtime_call_affinity_key_scopes_oauth_by_principal() -> None: +def test_realtime_call_affinity_key_scopes_oauth_by_credential_digest() -> None: scope_a = RealtimeCallerScope.for_oauth( - principal_id="caller-a", + affinity_scope_material="oauth-local:caller-a", allowed_account_ids={"upstream-a"}, ) scope_b = RealtimeCallerScope.for_oauth( - principal_id="caller-b", + affinity_scope_material="oauth-local:caller-b", allowed_account_ids={"upstream-a"}, ) diff --git a/tests/unit/test_auth_dependencies_upstream_proxy.py b/tests/unit/test_auth_dependencies_upstream_proxy.py index 053f7a6ce8..08ae665e05 100644 --- a/tests/unit/test_auth_dependencies_upstream_proxy.py +++ b/tests/unit/test_auth_dependencies_upstream_proxy.py @@ -1,26 +1,36 @@ from __future__ import annotations +from contextlib import asynccontextmanager from types import SimpleNamespace from typing import Any, cast import pytest from app.core.auth import dependencies as auth_dependencies -from app.core.auth.codex_oauth_identity import VerifiedCodexOAuthIdentity -from app.core.exceptions import ProxyAuthError -from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute +from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute, UpstreamProxyRouteError from app.core.usage.models import UsagePayload -from app.modules.api_keys.service import ApiKeyData +from app.db.models import Account, AccountStatus pytestmark = pytest.mark.unit +def _account() -> Account: + return Account( + id="acc_1", + chatgpt_account_id="chatgpt_1", + email="acc@example.com", + access_token_encrypted=b"access", + refresh_token_encrypted=b"refresh", + id_token_encrypted=b"id", + status=AccountStatus.ACTIVE, + ) + + @pytest.mark.asyncio -async def test_validate_codex_usage_identity_projects_verified_identity_to_request_state( - monkeypatch: pytest.MonkeyPatch, -) -> None: +async def test_validate_codex_usage_identity_passes_resolved_route(monkeypatch: pytest.MonkeyPatch) -> None: + account = _account() request = SimpleNamespace( - headers={"Authorization": "Bearer oauth-token", "chatgpt-account-id": "workspace_account"}, + headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, state=SimpleNamespace(), ) route = ResolvedUpstreamRoute( @@ -28,94 +38,260 @@ async def test_validate_codex_usage_identity_projects_verified_identity_to_reque pool_id="pool_1", endpoint=ResolvedProxyEndpoint("ep_1", "http", "proxy.test", 8080), ) - usage_payload = UsagePayload(workspace_id="workspace_1", workspace_label="Team") - identity = VerifiedCodexOAuthIdentity( - principal_id="acc_2", - caller_account_id="acc_2", - chatgpt_account_id="workspace_account", - usage_payload=usage_payload, - route=route, - ) - calls: list[tuple[str | None, str | None]] = [] + calls: dict[str, Any] = {} + + class Repo: + def __init__(self, session: object) -> None: + calls["repo_session"] = session + + async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: + calls["lookup"] = chatgpt_account_id + return account - async def resolve_identity( - authorization: str | None, - chatgpt_account_id: str | None, - ) -> VerifiedCodexOAuthIdentity: - calls.append((authorization, chatgpt_account_id)) - return identity + @asynccontextmanager + async def session_context(): + yield object() - monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", resolve_identity) + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + calls["resolve_kwargs"] = kwargs + return route + + async def fetch_usage(*args: object, **kwargs: object) -> None: + calls["fetch_kwargs"] = kwargs + + monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) + monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) + monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) assert result is None - assert calls == [("Bearer oauth-token", "workspace_account")] - assert request.state.codex_usage_identity_access_token == "oauth-token" - assert request.state.codex_usage_identity_chatgpt_account_id == "workspace_account" - assert request.state.codex_usage_identity_account_id == "acc_2" + assert calls["lookup"] == "chatgpt_1" + assert calls["resolve_kwargs"]["account_id"] == "acc_1" + assert calls["resolve_kwargs"]["operation"] == "usage_identity" + assert calls["fetch_kwargs"]["route"] is route + assert request.state.codex_usage_identity_access_token == "access" + assert request.state.codex_usage_identity_chatgpt_account_id == "chatgpt_1" + assert request.state.codex_usage_identity_account_id == "acc_1" assert request.state.codex_usage_identity_route is route - assert request.state.codex_usage_identity_payload is usage_payload @pytest.mark.asyncio -async def test_validate_codex_usage_identity_rejects_external_oauth_principal( +async def test_validate_codex_usage_identity_reresolves_route_for_workspace_account( monkeypatch: pytest.MonkeyPatch, ) -> None: + account = _account() + workspace_account = _account() + workspace_account.id = auth_dependencies.generate_unique_account_id( + account.chatgpt_account_id, + account.email, + "ws_1", + "Team", + ) request = SimpleNamespace( - headers={"Authorization": "Bearer oauth-token", "chatgpt-account-id": "workspace_account"}, + headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, state=SimpleNamespace(), ) - identity = VerifiedCodexOAuthIdentity( - principal_id="principal:user-external", - caller_account_id=None, - chatgpt_account_id="workspace_account", - usage_payload=UsagePayload(workspace_id="workspace_1"), - route=None, + owner_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="owner_pool", + endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), ) + workspace_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="workspace_pool", + endpoint=ResolvedProxyEndpoint("workspace_ep", "http", "workspace-proxy.test", 8080), + ) + resolved_account_ids: list[str] = [] - async def resolve_identity( - authorization: str | None, - chatgpt_account_id: str | None, - ) -> VerifiedCodexOAuthIdentity: - return identity + class Repo: + def __init__(self, session: object) -> None: + pass - monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", resolve_identity) + async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: + return account if chatgpt_account_id == "chatgpt_1" else None - with pytest.raises(ProxyAuthError, match="eligible imported account"): - await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) + async def get_by_id(self, account_id: str) -> Account | None: + return workspace_account if account_id == workspace_account.id else None - assert not hasattr(request.state, "codex_usage_identity_payload") + @asynccontextmanager + async def session_context(): + yield object() + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + account_id = cast(str, kwargs["account_id"]) + resolved_account_ids.append(account_id) + return workspace_route if account_id == workspace_account.id else owner_route + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + assert kwargs["route"] is owner_route + return UsagePayload(workspace_id="ws_1", workspace_label="Team") + + monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) + monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) + monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) + + result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) + + assert result is None + assert resolved_account_ids == ["acc_1", workspace_account.id] + assert request.state.codex_usage_identity_account_id == workspace_account.id + assert request.state.codex_usage_identity_route is workspace_route + + +@pytest.mark.parametrize("status", [AccountStatus.RATE_LIMITED, AccountStatus.QUOTA_EXCEEDED]) @pytest.mark.asyncio -async def test_validate_codex_usage_identity_keeps_proxy_key_on_key_path( +async def test_validate_codex_usage_identity_reresolves_route_for_limited_workspace_account( monkeypatch: pytest.MonkeyPatch, + status: AccountStatus, ) -> None: + account = _account() + workspace_account = _account() + workspace_account.id = auth_dependencies.generate_unique_account_id( + account.chatgpt_account_id, + account.email, + "ws_1", + "Team", + ) + workspace_account.status = status request = SimpleNamespace( - headers={"Authorization": "Bearer sk-clb-test", "chatgpt-account-id": "ignored"}, + headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, state=SimpleNamespace(), ) - expected = cast(ApiKeyData, SimpleNamespace(id="key_1")) + owner_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="owner_pool", + endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), + ) + workspace_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="workspace_pool", + endpoint=ResolvedProxyEndpoint("workspace_ep", "http", "workspace-proxy.test", 8080), + ) + resolved_account_ids: list[str] = [] + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: + return account if chatgpt_account_id == "chatgpt_1" else None + + async def get_by_id(self, account_id: str) -> Account | None: + return workspace_account if account_id == workspace_account.id else None - async def validate_key(token: str) -> ApiKeyData: - assert token == "sk-clb-test" - return expected + @asynccontextmanager + async def session_context(): + yield object() - async def unexpected_oauth(*args: object) -> VerifiedCodexOAuthIdentity: - raise AssertionError("OAuth resolver must not receive a Proxy API Key") + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + account_id = cast(str, kwargs["account_id"]) + resolved_account_ids.append(account_id) + return workspace_route if account_id == workspace_account.id else owner_route - monkeypatch.setattr(auth_dependencies, "_validate_api_key_token", validate_key) - monkeypatch.setattr(auth_dependencies, "resolve_verified_codex_oauth_identity", unexpected_oauth) + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + assert kwargs["route"] is owner_route + return UsagePayload(workspace_id="ws_1", workspace_label="Team") + + monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) + monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) + monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) result = await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) - assert result is expected + assert result is None + assert resolved_account_ids == ["acc_1", workspace_account.id] + assert request.state.codex_usage_identity_account_id == workspace_account.id + assert request.state.codex_usage_identity_route is workspace_route @pytest.mark.asyncio -async def test_validate_codex_usage_identity_rejects_missing_bearer() -> None: - request = SimpleNamespace(headers={"chatgpt-account-id": "workspace_account"}, state=SimpleNamespace()) +async def test_validate_codex_usage_identity_rejects_inactive_workspace_account( + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account() + workspace_account = _account() + workspace_account.id = auth_dependencies.generate_unique_account_id( + account.chatgpt_account_id, + account.email, + "ws_1", + "Team", + ) + workspace_account.status = AccountStatus.PAUSED + request = SimpleNamespace( + headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"}, + state=SimpleNamespace(), + ) + owner_route = ResolvedUpstreamRoute( + mode="account_bound", + pool_id="owner_pool", + endpoint=ResolvedProxyEndpoint("owner_ep", "http", "owner-proxy.test", 8080), + ) + resolved_account_ids: list[str] = [] + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: + return account if chatgpt_account_id == "chatgpt_1" else None + + async def get_by_id(self, account_id: str) -> Account | None: + return workspace_account if account_id == workspace_account.id else None + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + resolved_account_ids.append(cast(str, kwargs["account_id"])) + return owner_route - with pytest.raises(ProxyAuthError, match="Missing ChatGPT token"): + async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: + assert kwargs["route"] is owner_route + return UsagePayload(workspace_id="ws_1", workspace_label="Team") + + monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) + monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) + monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) + monkeypatch.setattr(auth_dependencies, "fetch_usage", fetch_usage) + + with pytest.raises(auth_dependencies.ProxyAuthError): await auth_dependencies.validate_codex_usage_identity(cast(Any, request)) + + assert resolved_account_ids == ["acc_1"] + assert not hasattr(request.state, "codex_usage_identity_account_id") + assert not hasattr(request.state, "codex_usage_identity_route") + + +@pytest.mark.asyncio +async def test_validate_codex_usage_identity_fails_closed_when_route_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account() + + class Repo: + def __init__(self, session: object) -> None: + pass + + async def get_active_by_chatgpt_account_id(self, chatgpt_account_id: str) -> Account | None: + return account + + @asynccontextmanager + async def session_context(): + yield object() + + async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: + raise UpstreamProxyRouteError("default_pool_unconfigured", account_id="acc_1") + + monkeypatch.setattr(auth_dependencies, "get_background_session", session_context) + monkeypatch.setattr(auth_dependencies, "AccountsRepository", Repo) + monkeypatch.setattr(auth_dependencies, "resolve_upstream_route", resolve_route) + + with pytest.raises(auth_dependencies.ProxyUpstreamError): + await auth_dependencies.validate_codex_usage_identity( + cast(Any, SimpleNamespace(headers={"Authorization": "Bearer access", "chatgpt-account-id": "chatgpt_1"})) + ) diff --git a/tests/unit/test_codex_oauth_identity.py b/tests/unit/test_codex_oauth_identity.py deleted file mode 100644 index 11f219f80d..0000000000 --- a/tests/unit/test_codex_oauth_identity.py +++ /dev/null @@ -1,636 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import json -from contextlib import asynccontextmanager -from typing import Any - -import pytest - -from app.core.auth import codex_oauth_identity -from app.core.clients.usage import UsageFetchError -from app.core.exceptions import ProxyAuthError, ProxyRateLimitError, ProxyUpstreamError -from app.core.upstream_proxy import ResolvedProxyEndpoint, ResolvedUpstreamRoute, UpstreamProxyRouteError -from app.core.usage.models import UsagePayload -from app.db.models import Account, AccountStatus - -pytestmark = pytest.mark.unit - - -def _jwt(payload: dict[str, Any]) -> str: - encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - return f"header.{encoded}.signature" - - -def _account( - account_id: str, - *, - chatgpt_user_id: str | None, - id_token: bytes, -) -> Account: - return Account( - id=account_id, - chatgpt_account_id="workspace_account", - chatgpt_user_id=chatgpt_user_id, - email=f"{account_id}@example.com", - workspace_id="workspace_1", - workspace_label="Team", - plan_type="team", - access_token_encrypted=b"access", - refresh_token_encrypted=b"refresh", - id_token_encrypted=id_token, - status=AccountStatus.ACTIVE, - ) - - -def _install_single_account_fakes( - monkeypatch: pytest.MonkeyPatch, - fetch_usage: Any, -) -> tuple[Account, ResolvedUpstreamRoute]: - account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") - route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="pool", - endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), - ) - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - return [account] - - class Encryptor: - def decrypt(self, ciphertext: bytes) -> str: - return _jwt({"chatgpt_user_id": "user_a"}) - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - return route - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) - monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - return account, route - - -@pytest.fixture(autouse=True) -def _clear_identity_cache() -> None: - codex_oauth_identity.clear_codex_oauth_identity_cache() - - -@pytest.mark.asyncio -async def test_verified_oauth_principal_does_not_require_an_imported_account( - monkeypatch: pytest.MonkeyPatch, -) -> None: - access_token = _jwt({"chatgpt_user_id": "user-external"}) - - class EmptyRepo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, _account_id: str) -> list[Account]: - return [] - - @asynccontextmanager - async def session_context(): - yield object() - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - assert kwargs["access_token"] == access_token - assert kwargs["account_id"] == "workspace-external" - assert kwargs["route"] is None - assert kwargs["allow_direct_egress"] is True - return UsagePayload(workspace_id="workspace-external") - - async def resolve_route(*args: object, **kwargs: object) -> None: - assert kwargs["account_id"] is None - return None - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", EmptyRepo) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - - identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace-external", - ) - - assert identity.principal_id == "principal:user-external" - assert identity.caller_account_id is None - assert identity.route is None - - -@pytest.mark.asyncio -async def test_imported_seat_lifecycle_preserves_stable_principal_and_updates_route( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account_a = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") - account_b = _account("acc_b", chatgpt_user_id="user_b", id_token=b"id_b") - access_token = _jwt({"sub": "auth0|seat_b"}) - stored_tokens = { - b"id_a": _jwt({"sub": "auth0|seat_a"}), - b"id_b": _jwt({"sub": "auth0|seat_b"}), - } - caller_route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="caller_pool", - endpoint=ResolvedProxyEndpoint("caller_ep", "http", "caller.test", 8080), - ) - default_route = ResolvedUpstreamRoute( - mode="default", - pool_id="default_pool", - endpoint=ResolvedProxyEndpoint("default_ep", "http", "default.test", 8080), - ) - eligible_accounts = [account_a, account_b] - route_account_ids: list[str | None] = [] - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - assert chatgpt_account_id == "workspace_account" - return list(eligible_accounts) - - class Encryptor: - def decrypt(self, ciphertext: bytes) -> str: - return stored_tokens[ciphertext] - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - account_id = kwargs["account_id"] - assert account_id is None or isinstance(account_id, str) - route_account_ids.append(account_id) - return default_route if account_id is None else caller_route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - assert kwargs["access_token"] == access_token - assert kwargs["account_id"] == "workspace_account" - assert kwargs["route"] in (caller_route, default_route) - return UsagePayload(workspace_id="workspace_1", workspace_label="Team") - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) - monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - - imported_identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - " workspace_account ", - ) - - assert imported_identity.caller_account_id == "acc_b" - assert imported_identity.principal_id == "principal:auth0|seat_b" - assert imported_identity.chatgpt_account_id == "workspace_account" - assert imported_identity.usage_payload.workspace_id == "workspace_1" - assert imported_identity.route is caller_route - - codex_oauth_identity.clear_codex_oauth_identity_cache() - eligible_accounts.clear() - external_identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert external_identity.caller_account_id is None - assert external_identity.principal_id == imported_identity.principal_id - assert external_identity.route is default_route - assert route_account_ids == ["acc_b", None] - - -@pytest.mark.asyncio -async def test_identity_without_stable_claim_uses_unique_imported_account_fallback( - monkeypatch: pytest.MonkeyPatch, -) -> None: - access_token = _jwt({}) - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - return UsagePayload(workspace_id="workspace_1", workspace_label="Team") - - account, route = _install_single_account_fakes(monkeypatch, fetch_usage) - - identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert identity.principal_id == account.id - assert identity.caller_account_id == account.id - assert identity.route is route - - -@pytest.mark.asyncio -async def test_identity_route_failure_maps_to_credential_safe_upstream_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - access_token = _jwt({"chatgpt_user_id": "user_a"}) - - async def unused_fetch(*args: object, **kwargs: object) -> UsagePayload: - raise AssertionError("usage validation must remain unreachable") - - _install_single_account_fakes(monkeypatch, unused_fetch) - - async def fail_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - raise UpstreamProxyRouteError("default_pool_unconfigured", account_id="acc_a") - - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", fail_route) - - with pytest.raises(ProxyUpstreamError, match="Unable to resolve upstream proxy route") as exc_info: - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert access_token not in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_shared_workspace_identity_fails_closed_when_ambiguous( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account_a = _account("acc_a", chatgpt_user_id=None, id_token=b"id_a") - account_b = _account("acc_b", chatgpt_user_id=None, id_token=b"id_b") - access_token = _jwt({}) - route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="pool", - endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), - ) - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - return [account_a, account_b] - - class Encryptor: - def decrypt(self, ciphertext: bytes) -> str: - return _jwt({}) - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - return route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - return UsagePayload(workspace_id="workspace_1") - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) - monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - - with pytest.raises(ProxyAuthError, match="Unknown or ambiguous ChatGPT identity"): - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - -@pytest.mark.asyncio -async def test_concurrent_identity_resolution_coalesces_upstream_validation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") - access_token = _jwt({"chatgpt_user_id": "user_a"}) - route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="pool", - endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), - ) - fetch_count = 0 - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - return [account] - - class Encryptor: - def decrypt(self, ciphertext: bytes) -> str: - return _jwt({"chatgpt_user_id": "user_a"}) - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - return route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - return UsagePayload(workspace_id="workspace_1") - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) - monkeypatch.setattr(codex_oauth_identity, "TokenEncryptor", Encryptor) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - - first, second = await asyncio.gather( - codex_oauth_identity.resolve_verified_codex_oauth_identity(f"Bearer {access_token}", "workspace_account"), - codex_oauth_identity.resolve_verified_codex_oauth_identity(f"Bearer {access_token}", "workspace_account"), - ) - - assert first == second - assert fetch_count == 1 - - -@pytest.mark.asyncio -async def test_positive_cache_ttl_tracks_token_expiry_and_rotation_uses_a_new_key( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fetch_count = 0 - wall_time = 1_000.0 - monotonic_time = 200.0 - short_token = _jwt({"chatgpt_user_id": "user_a", "exp": 1_010}) - rotated_token = _jwt({"chatgpt_user_id": "user_a", "exp": 1_600}) - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, fetch_usage) - monkeypatch.setattr(codex_oauth_identity.time, "time", lambda: wall_time) - monkeypatch.setattr(codex_oauth_identity.time, "monotonic", lambda: monotonic_time) - - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {short_token}", - "workspace_account", - ) - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {rotated_token}", - "workspace_account", - ) - - short_key = codex_oauth_identity._credential_digest(short_token, "workspace_account") - rotated_key = codex_oauth_identity._credential_digest(rotated_token, "workspace_account") - assert short_key != rotated_key - assert codex_oauth_identity._identity_cache[short_key].expires_at == pytest.approx(210.0) - assert codex_oauth_identity._identity_cache[rotated_key].expires_at == pytest.approx(260.0) - assert fetch_count == 2 - - -@pytest.mark.asyncio -async def test_cancelled_waiter_does_not_cancel_shared_validation(monkeypatch: pytest.MonkeyPatch) -> None: - fetch_started = asyncio.Event() - release_fetch = asyncio.Event() - fetch_count = 0 - access_token = _jwt({"chatgpt_user_id": "user_a"}) - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - fetch_started.set() - await release_fetch.wait() - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, fetch_usage) - - cancelled_waiter = asyncio.create_task( - codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - ) - await fetch_started.wait() - surviving_waiter = asyncio.create_task( - codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - ) - await asyncio.sleep(0) - - cancelled_waiter.cancel() - with pytest.raises(asyncio.CancelledError): - await cancelled_waiter - release_fetch.set() - - identity = await surviving_waiter - assert identity.caller_account_id == "acc_a" - assert fetch_count == 1 - - -@pytest.mark.asyncio -async def test_cancelled_only_waiter_cancels_and_drains_validation(monkeypatch: pytest.MonkeyPatch) -> None: - fetch_started = asyncio.Event() - fetch_cancelled = asyncio.Event() - release_fetch = asyncio.Event() - release_cancellation = asyncio.Event() - access_token = _jwt({"chatgpt_user_id": "user_a"}) - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - fetch_started.set() - try: - await release_fetch.wait() - except asyncio.CancelledError: - fetch_cancelled.set() - await release_cancellation.wait() - raise - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, fetch_usage) - waiter = asyncio.create_task( - codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - ) - - await fetch_started.wait() - waiter.cancel() - with pytest.raises(asyncio.CancelledError): - await waiter - - await asyncio.wait_for(fetch_cancelled.wait(), timeout=1.0) - assert len(codex_oauth_identity._identity_inflight) == 1 - with pytest.raises(ProxyRateLimitError, match="still being cancelled"): - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - release_cancellation.set() - await asyncio.sleep(0) - await asyncio.sleep(0) - assert codex_oauth_identity._identity_inflight == {} - - -@pytest.mark.asyncio -async def test_distinct_identity_validation_capacity_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: - fetch_count = 0 - all_fetches_started = asyncio.Event() - release_fetches = asyncio.Event() - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - if fetch_count == codex_oauth_identity._INFLIGHT_MAX_ENTRIES: - all_fetches_started.set() - await release_fetches.wait() - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, fetch_usage) - waiters = [ - asyncio.create_task( - codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {_jwt({'chatgpt_user_id': 'user_a', 'nonce': index})}", - "workspace_account", - ) - ) - for index in range(codex_oauth_identity._INFLIGHT_MAX_ENTRIES) - ] - - await asyncio.wait_for(all_fetches_started.wait(), timeout=1.0) - overflow_token = _jwt({"chatgpt_user_id": "user_a", "nonce": "overflow"}) - with pytest.raises(ProxyRateLimitError, match="Too many concurrent"): - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {overflow_token}", - "workspace_account", - ) - assert fetch_count == codex_oauth_identity._INFLIGHT_MAX_ENTRIES - - release_fetches.set() - await asyncio.gather(*waiters) - await asyncio.sleep(0) - assert codex_oauth_identity._identity_inflight == {} - - -@pytest.mark.parametrize( - ("status_code", "expected_error"), - [(429, ProxyRateLimitError), (503, ProxyUpstreamError)], -) -@pytest.mark.asyncio -async def test_transient_validation_failure_is_not_cached( - monkeypatch: pytest.MonkeyPatch, - status_code: int, - expected_error: type[Exception], -) -> None: - access_token = _jwt({"chatgpt_user_id": "user_a"}) - fetch_count = 0 - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - if fetch_count == 1: - raise UsageFetchError(status_code, f"transient {access_token}") - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, fetch_usage) - - with pytest.raises(expected_error) as exc_info: - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - assert access_token not in str(exc_info.value) - - identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - assert identity.caller_account_id == "acc_a" - assert fetch_count == 2 - - -@pytest.mark.asyncio -async def test_identity_cache_public_result_and_denial_do_not_retain_raw_token( - monkeypatch: pytest.MonkeyPatch, -) -> None: - access_token = _jwt({"chatgpt_user_id": "user_a"}) - - async def successful_fetch(*args: object, **kwargs: object) -> UsagePayload: - return UsagePayload(workspace_id="workspace_1") - - _install_single_account_fakes(monkeypatch, successful_fetch) - identity = await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert not hasattr(identity, "access_token") - assert access_token not in repr(identity) - assert all(access_token not in key for key in codex_oauth_identity._identity_cache) - - codex_oauth_identity.clear_codex_oauth_identity_cache() - - async def denied_fetch(*args: object, **kwargs: object) -> UsagePayload: - raise UsageFetchError(401, f"rejected bearer {access_token}") - - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", denied_fetch) - with pytest.raises(ProxyAuthError) as exc_info: - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert access_token not in str(exc_info.value) - assert access_token not in repr(codex_oauth_identity._identity_cache) - - -@pytest.mark.asyncio -async def test_credential_denial_is_cached_without_repeating_upstream_validation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - account = _account("acc_a", chatgpt_user_id="user_a", id_token=b"id_a") - access_token = _jwt({"chatgpt_user_id": "user_a"}) - route = ResolvedUpstreamRoute( - mode="account_bound", - pool_id="pool", - endpoint=ResolvedProxyEndpoint("ep", "http", "proxy.test", 8080), - ) - fetch_count = 0 - - class Repo: - def __init__(self, session: object) -> None: - pass - - async def list_eligible_by_chatgpt_account_id(self, chatgpt_account_id: str) -> list[Account]: - return [account] - - @asynccontextmanager - async def session_context(): - yield object() - - async def resolve_route(*args: object, **kwargs: object) -> ResolvedUpstreamRoute: - return route - - async def fetch_usage(*args: object, **kwargs: object) -> UsagePayload: - nonlocal fetch_count - fetch_count += 1 - raise UsageFetchError(401, "credential rejected") - - monkeypatch.setattr(codex_oauth_identity, "AccountsRepository", Repo) - monkeypatch.setattr(codex_oauth_identity, "get_background_session", session_context) - monkeypatch.setattr(codex_oauth_identity, "resolve_upstream_route", resolve_route) - monkeypatch.setattr(codex_oauth_identity, "fetch_usage", fetch_usage) - - for _ in range(2): - with pytest.raises(ProxyAuthError, match="Invalid ChatGPT token"): - await codex_oauth_identity.resolve_verified_codex_oauth_identity( - f"Bearer {access_token}", - "workspace_account", - ) - - assert fetch_count == 1 diff --git a/tests/unit/test_realtime_auth.py b/tests/unit/test_realtime_auth.py index febd386806..d48140108f 100644 --- a/tests/unit/test_realtime_auth.py +++ b/tests/unit/test_realtime_auth.py @@ -4,37 +4,138 @@ from typing import cast import pytest +from starlette.requests import HTTPConnection -import app.core.auth.codex_oauth_identity as oauth_identity_module +import app.core.auth.dependencies as auth_dependencies +import app.core.request_locality as request_locality import app.modules.proxy.realtime_auth as realtime_auth_module from app.core.exceptions import ProxyAuthError from app.modules.api_keys.service import ApiKeyData +def _connection() -> HTTPConnection: + return HTTPConnection( + { + "type": "http", + "headers": [], + "client": ("127.0.0.1", 50000), + "server": ("127.0.0.1", 2455), + } + ) + + +def _guarded_connection(*, client_host: str, host: str) -> HTTPConnection: + return HTTPConnection( + { + "type": "http", + "headers": [(b"host", host.encode())], + "client": (client_host, 50000), + "server": (host, 2455), + "_codex_lb_raw_socket_peer": (client_host, 50000), + } + ) + + +def _configure_keyless_guard( + monkeypatch: pytest.MonkeyPatch, + *, + api_key_auth_enabled: bool, + allowed_cidrs: list[str] | None = None, +) -> None: + async def load_auth_setting() -> SimpleNamespace: + return SimpleNamespace(api_key_auth_enabled=api_key_auth_enabled) + + monkeypatch.setattr( + auth_dependencies, + "get_settings_cache", + lambda: SimpleNamespace(get=load_auth_setting), + ) + monkeypatch.setattr( + auth_dependencies, + "get_settings", + lambda: SimpleNamespace(proxy_unauthenticated_client_cidrs=allowed_cidrs or []), + ) + monkeypatch.setattr( + request_locality, + "get_settings", + lambda: SimpleNamespace( + firewall_trust_proxy_headers=False, + firewall_trusted_proxy_cidrs=[], + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("client_host", "host", "allowed_cidrs"), + [ + ("127.0.0.1", "localhost", []), + ("192.168.65.1", "lb.example", ["192.168.65.1/32"]), + ], + ids=["loopback", "configured-raw-peer-cidr"], +) +async def test_keyless_live_reuses_existing_unauthenticated_proxy_admission( + monkeypatch: pytest.MonkeyPatch, + client_host: str, + host: str, + allowed_cidrs: list[str], +) -> None: + _configure_keyless_guard( + monkeypatch, + api_key_auth_enabled=False, + allowed_cidrs=allowed_cidrs, + ) + + await realtime_auth_module._validate_keyless_origin(_guarded_connection(client_host=client_host, host=host)) + + @pytest.mark.asyncio -async def test_sk_clb_bearer_uses_strict_key_path_without_oauth_fallback( +@pytest.mark.parametrize( + ("api_key_auth_enabled", "client_host", "host", "message"), + [ + (False, "203.0.113.10", "lb.example", "remote access"), + (True, "127.0.0.1", "localhost", "Missing API key"), + ], + ids=["untrusted-remote", "api-key-mode"], +) +async def test_keyless_live_guard_fails_closed( monkeypatch: pytest.MonkeyPatch, + api_key_auth_enabled: bool, + client_host: str, + host: str, + message: str, ) -> None: - oauth_calls = 0 + _configure_keyless_guard( + monkeypatch, + api_key_auth_enabled=api_key_auth_enabled, + ) + + with pytest.raises(ProxyAuthError, match=message): + await realtime_auth_module._validate_keyless_origin(_guarded_connection(client_host=client_host, host=host)) + + +@pytest.mark.asyncio +async def test_sk_clb_bearer_uses_strict_key_path_without_keyless_fallback() -> None: + origin_calls = 0 async def reject_key(_authorization: str | None) -> ApiKeyData: raise ProxyAuthError("invalid registered key") - async def unexpected_oauth(*_args: object) -> object: - nonlocal oauth_calls - oauth_calls += 1 - raise AssertionError("OAuth fallback must remain unreachable") - - monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", unexpected_oauth) + async def unexpected_origin(_connection: HTTPConnection) -> None: + nonlocal origin_calls + origin_calls += 1 + raise AssertionError("Keyless fallback must remain unreachable") with pytest.raises(ProxyAuthError, match="invalid registered key"): await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), "Bearer sk-clb-invalid", "workspace-id", api_key_validator=reject_key, + keyless_origin_validator=unexpected_origin, ) - assert oauth_calls == 0 + assert origin_calls == 0 @pytest.mark.asyncio @@ -52,6 +153,7 @@ async def validate_key(_authorization: str | None) -> ApiKeyData: return api_key scope = await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), "Bearer sk-clb-valid", None, api_key_validator=validate_key, @@ -64,49 +166,132 @@ async def validate_key(_authorization: str | None) -> ApiKeyData: @pytest.mark.asyncio -async def test_oauth_scope_uses_verified_principal_and_fresh_global_policy( +async def test_oauth_scope_uses_keyless_origin_and_credential_affinity( monkeypatch: pytest.MonkeyPatch, ) -> None: - identity = SimpleNamespace(principal_id="principal-1") + connection = _connection() + origin_connections: list[HTTPConnection] = [] + affinity_inputs: list[tuple[str, str]] = [] - async def resolve_identity(_authorization: str | None, _account_id: str | None) -> object: - return identity + async def validate_origin(candidate: HTTPConnection) -> None: + origin_connections.append(candidate) async def load_policy() -> frozenset[str]: return frozenset({"allowed-a", "allowed-b"}) - monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", resolve_identity) + def build_affinity(token: str, account_id: str) -> str: + affinity_inputs.append((token, account_id)) + return "oauth-local:digest" + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) scope = await realtime_auth_module.resolve_realtime_caller_scope( + connection, "Bearer oauth-token", - "workspace-id", + " workspace-id ", + keyless_origin_validator=validate_origin, + affinity_material_builder=build_affinity, ) + assert origin_connections == [connection] + assert affinity_inputs == [("oauth-token", "workspace-id")] assert scope.kind == "oauth" - assert scope.affinity_scope_material == "oauth:principal-1" + assert scope.affinity_scope_material == "oauth-local:digest" assert scope.api_key is None assert scope.allowed_account_ids == frozenset({"allowed-a", "allowed-b"}) +@pytest.mark.asyncio +async def test_oauth_scope_rejects_untrusted_origin_before_policy_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + policy_calls = 0 + + async def reject_origin(_connection: HTTPConnection) -> None: + raise ProxyAuthError("Proxy authentication must be configured before remote access is allowed") + + async def unexpected_policy() -> frozenset[str]: + nonlocal policy_calls + policy_calls += 1 + return frozenset({"allowed-a"}) + + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", unexpected_policy) + + with pytest.raises(ProxyAuthError, match="remote access"): + await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), + "Bearer oauth-token", + "workspace-id", + keyless_origin_validator=reject_origin, + ) + + assert policy_calls == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("authorization", "account_id", "message"), + [ + (None, "workspace-id", "Missing ChatGPT token"), + ("Basic oauth-token", "workspace-id", "Missing ChatGPT token"), + ("Bearer oauth-token", None, "Missing chatgpt-account-id"), + ("Bearer oauth-token", " ", "Missing chatgpt-account-id"), + ], +) +async def test_oauth_scope_requires_bearer_and_account_header( + authorization: str | None, + account_id: str | None, + message: str, +) -> None: + async def allow_origin(_connection: HTTPConnection) -> None: + return None + + with pytest.raises(ProxyAuthError, match=message): + await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), + authorization, + account_id, + keyless_origin_validator=allow_origin, + ) + + @pytest.mark.asyncio async def test_oauth_scope_fails_closed_when_policy_has_no_active_accounts( monkeypatch: pytest.MonkeyPatch, ) -> None: - async def resolve_identity(_authorization: str | None, _account_id: str | None) -> object: - return SimpleNamespace(principal_id="principal-1") + async def validate_origin(_connection: HTTPConnection) -> None: + return None async def load_policy() -> frozenset[str]: return frozenset() - monkeypatch.setattr(oauth_identity_module, "resolve_verified_codex_oauth_identity", resolve_identity) monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) with pytest.raises(realtime_auth_module.OAuthLiveNotEnabledError) as raised: await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), "Bearer oauth-token", "workspace-id", + keyless_origin_validator=validate_origin, ) assert raised.value.status_code == 403 assert raised.value.code == "oauth_live_not_enabled" + + +def test_oauth_affinity_is_deterministic_credential_bound_and_secret_free( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(realtime_auth_module, "get_or_create_key", lambda: b"persistent-test-key") + + first = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-a") + repeated = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-a") + rotated = realtime_auth_module._oauth_live_affinity_scope_material("token-b", "account-a") + other_account = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-b") + + assert first == repeated + assert first.startswith("oauth-local:") + assert len(first) == len("oauth-local:") + 64 + assert len({first, rotated, other_account}) == 3 + assert "token-a" not in first + assert "account-a" not in first diff --git a/tests/unit/test_realtime_live.py b/tests/unit/test_realtime_live.py index e04ffd2dc4..a24c828c67 100644 --- a/tests/unit/test_realtime_live.py +++ b/tests/unit/test_realtime_live.py @@ -897,7 +897,7 @@ async def fake_connect_live_websocket(*_args, **_kwargs): service = _ProxyService(account, lease, live_websocket_connector=fake_connect_live_websocket) caller_scope = RealtimeCallerScope.for_oauth( - principal_id="caller-account", + affinity_scope_material="oauth-local:caller-account", allowed_account_ids={"account-a"}, ) @@ -921,7 +921,7 @@ async def fake_connect_live_websocket(*_args, **_kwargs): async def test_oauth_live_sideband_rejects_owner_removed_from_current_policy_before_selection() -> None: service = _ProxyService(SimpleNamespace(id="account-a"), None) caller_scope = RealtimeCallerScope.for_oauth( - principal_id="caller-account", + affinity_scope_material="oauth-local:caller-account", allowed_account_ids={"account-b"}, ) From 5428e89f46310a3bafefd924e1340c7fbbcbeb23 Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 11:39:04 +0800 Subject: [PATCH 6/9] docs(openspec): describe final OAuth Live contract --- openspec/specs/database-migrations/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/specs/database-migrations/spec.md b/openspec/specs/database-migrations/spec.md index e6ee4265f9..f9f1a140b2 100644 --- a/openspec/specs/database-migrations/spec.md +++ b/openspec/specs/database-migrations/spec.md @@ -254,4 +254,4 @@ The database SHALL store at most one global OAuth Live policy with singleton id - **WHEN** the global revision downgrades - **THEN** the global relationship table is removed before the singleton table -- **AND** the prior schema head is restored +- **AND** the database has no OAuth Live policy tables From 0bc228d88b235c2102e8615cefe13c9b2bb165a1 Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 12:34:06 +0800 Subject: [PATCH 7/9] docs(live): complete OAuth Live operator guide --- docs/client-setup.md | 9 +++++++++ docs/configuration.md | 2 +- docs/index.md | 2 ++ docs/live-voice.md | 31 +++++++++++++++++++++++++++++++ docs/troubleshooting.md | 25 +++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/client-setup.md b/docs/client-setup.md index 21f37e6ef4..6e4a705148 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -31,6 +31,15 @@ supports_websockets = true requires_openai_auth = true # required for codex app ``` +### Live Voice profiles + +Codex Live Voice supports two profiles: + +- Keep `model_provider = "openai"` when the client must use official ChatGPT OAuth without a Codex-LB API Key. Enable the global account pool under **Settings → Live Voice**. +- Use the `codex-lb` provider with `env_key = "CODEX_LB_API_KEY"` when the client should use a registered Proxy API Key and its assignments, limits, and attribution. + +Both profiles must route WebRTC call creation and the control sideband to codex-lb. See [Codex Live Voice](live-voice.md) for the complete configuration and security boundary. + This documented `requires_openai_auth = true` setup uses Codex-backed authentication and does not need an `x-openai-actor-authorization` marker to be eligible for Codex's built-in `$imagegen` tool. Provider configurations that intentionally skip OpenAI login have a different eligibility path; see the [Images compatibility context](https://github.com/Soju06/codex-lb/blob/main/openspec/specs/images-api-compat/context.md#codex-provider-eligibility). ### WebSocket transport diff --git a/docs/configuration.md b/docs/configuration.md index b53b68e6ff..142af0c393 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,7 +26,7 @@ The remaining settings (timeouts, connection pools, bulkheads, session bridge, l - [Database backends](database.md) - [Troubleshooting](troubleshooting.md) -Runtime behavior such as the routing strategy, upstream stream transport, and per-account limits is configured live in the dashboard under **Settings** — no restart required. +Runtime behavior such as the routing strategy, upstream stream transport, per-account limits, and the OAuth Live Voice account pool is configured live in the dashboard under **Settings** — no restart required. See [Codex Live Voice](live-voice.md) for its default-off policy and client profiles. --- diff --git a/docs/index.md b/docs/index.md index 668ef7f154..f6d95b3ad5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,12 +12,14 @@ Load balancer for ChatGPT accounts. Pool multiple accounts, track usage, manage - **API keys** — per-key rate limits by token, cost, window, model - **Dashboard auth** — password + optional TOTP - **OpenAI-compatible** — Codex CLI, OpenCode, any OpenAI client +- **Live Voice affinity** — built-in Codex OAuth and registered Proxy API Key profiles stay on one upstream account across call creation and sideband - **Auto model sync** — available models fetched from upstream ## Where to go - [Getting Started](getting-started.md) — Docker / uvx quick start, remote bootstrap token - [Client Setup](client-setup.md) — Codex CLI, OpenCode, OpenClaw, Python SDK +- [Live Voice](live-voice.md) — OAuth and Proxy API Key profiles, account-pool policy, affinity, and failure behavior - [Configuration](configuration.md) — the few settings that matter - [Authentication](authentication.md) — dashboard auth modes - [Conversations](conversations.md) — dashboard view and conversation APIs diff --git a/docs/live-voice.md b/docs/live-voice.md index 0f9cb3d392..c143d17cc1 100644 --- a/docs/live-voice.md +++ b/docs/live-voice.md @@ -5,6 +5,24 @@ codex-lb keeps Live Voice call creation and its control sideband on the same ups !!! note "Private Codex compatibility" This capability covers the private routes used by Codex. WebRTC media remains peer-to-peer. +## How it works + +```mermaid +flowchart LR + OAuth["Built-in openai provider\nOfficial OAuth"] --> Origin["Zero-key origin admission"] + Origin --> GlobalPool["Settings-managed Live Voice pool"] + + Key["Registered sk-clb-* key"] --> KeyAuth["Key assignments and limits"] + KeyAuth --> KeyPool["Key account pool"] + + GlobalPool --> Create["Create Live call"] + KeyPool --> Create + Create --> Owner["Bind final serving account"] + Owner --> Sideband["Route sideband to the same account"] +``` + +The OAuth and registered-Key lanes share call ownership handling. Their admission rules and account pools remain independent. + ## Caller authentication The same private routes accept two caller types: @@ -16,6 +34,17 @@ The OAuth lane is available when global proxy API-key authentication is disabled codex-lb derives an opaque caller scope locally from the bearer and normalized `chatgpt-account-id`. It does not call OpenAI usage to authenticate Live requests. The network boundary grants keyless access; the credential pair separates call ownership between admitted clients. +## Configure the OAuth Live pool + +1. Import the upstream ChatGPT Accounts that may carry Live calls. +2. Open **Settings → Live Voice**. +3. Select the allowed upstream Accounts. +4. Enable **OAuth Live access** and save. + +The global policy starts disabled with an empty pool. Enabling it requires at least one Account. Runtime routing uses only Accounts that remain active. A selected Account that later becomes paused, deactivated, or requires reauthentication stays visible in the selector so it can be removed. + +This policy controls the OAuth lane only. Registered Proxy API Keys continue using their own assignments and limits. + ## Built-in OpenAI provider (OAuth) Use this profile when Codex must retain the built-in `openai` provider. It keeps official ChatGPT OAuth for conversations and Live Voice and requires no Codex-LB API Key in the client. @@ -79,4 +108,6 @@ Ownership records contain a caller-scoped digest and the owning account referenc - `404 realtime_call_not_found`: ownership is missing or the credential pair changed. - `503 realtime_call_binding_failed`: a successful upstream call could not be bound safely. +For client-side symptoms and route checks, see [Troubleshooting](troubleshooting.md#live-voice). + *Specs: [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat) · [database-migrations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/database-migrations) · [frontend-architecture](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/frontend-architecture)* diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 80121d75ae..7180b32c49 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -11,6 +11,31 @@ codex-lb refreshes usage on its own schedule and treats upstream samples conserv **Codex CLI falls back to POST instead of WebSockets.** Run the [WebSocket verification steps](client-setup.md#verify-websocket-transport). If codex-lb sits behind a reverse proxy, make sure it forwards WebSocket upgrades — see [Remote Access](deployment/remote.md). +## Live Voice + +**The Live Voice entry is missing in Codex Desktop.** +Keep official OpenAI authentication enabled in the client profile. A registered-Key profile uses `requires_openai_auth = true`; the built-in OAuth profile keeps `model_provider = "openai"`. Restart Codex Desktop after changing `config.toml`. + +**Voice chat takes too long to start and codex-lb receives no Live request.** +The client timed out before call creation. Confirm microphone permission and local audio-device initialization first. A codex-lb authentication failure always produces a request at one of the documented [Live Voice routes](live-voice.md#supported-private-routes). + +**Call creation succeeds but the sideband does not reach codex-lb.** +Set both experimental base URLs. The WebRTC base ends in `/backend-api/codex`; the WebSocket base ends in `/v1`: + +```toml +experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:2455/backend-api/codex" +experimental_realtime_ws_base_url = "http://127.0.0.1:2455/v1" +``` + +**OAuth Live returns `401 invalid_api_key`.** +The OAuth lane uses the zero-key origin boundary and is available while global Proxy API Key authentication is disabled. Loopback works without CIDR configuration. Remote clients use a registered Proxy API Key. + +**OAuth Live returns `403 oauth_live_not_enabled`.** +Open **Settings → Live Voice**, select at least one active upstream Account, enable **OAuth Live access**, and save. + +**A sideband returns `404 realtime_call_not_found`.** +The ownership binding is missing or the OAuth credential pair changed. Start a new Live call with the current credentials. + ## Fast Mode and service tiers Fast Mode and service-tier behavior is documented in the From 845bfb43dc47d888d86c286d677a6ccb7b0da107 Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 12:51:45 +0800 Subject: [PATCH 8/9] docs(live): link guides to owning spec --- docs/client-setup.md | 2 +- docs/configuration.md | 2 +- docs/troubleshooting.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/client-setup.md b/docs/client-setup.md index 6e4a705148..373f226a1a 100644 --- a/docs/client-setup.md +++ b/docs/client-setup.md @@ -295,4 +295,4 @@ print(response.choices[0].message.content) --- -*Specs: [responses-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/responses-api-compat) · [chat-completions-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/chat-completions-compat) · [model-catalog-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/model-catalog-compat) · [runtime-portability](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/runtime-portability)* +*Specs: [responses-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/responses-api-compat) · [chat-completions-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/chat-completions-compat) · [model-catalog-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/model-catalog-compat) · [runtime-portability](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/runtime-portability) · [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat)* diff --git a/docs/configuration.md b/docs/configuration.md index 142af0c393..e86a69f96a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,4 +30,4 @@ Runtime behavior such as the routing strategy, upstream stream transport, per-ac --- -*Specs: [deployment-installation](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/deployment-installation) · [replica-operations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/replica-operations)* +*Specs: [deployment-installation](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/deployment-installation) · [replica-operations](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/replica-operations) · [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat)* diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7180b32c49..136b1a90a2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -48,4 +48,4 @@ Fast Mode and service-tier behavior is documented in the --- -*Spec: [usage-refresh-policy](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/usage-refresh-policy)* +*Specs: [usage-refresh-policy](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/usage-refresh-policy) · [realtime-api-compat](https://github.com/Soju06/codex-lb/tree/main/openspec/specs/realtime-api-compat)* From 613d5d9f38ab9bf521492b4f6210be64846ffcee Mon Sep 17 00:00:00 2001 From: crowscc Date: Tue, 4 Aug 2026 17:12:51 +0800 Subject: [PATCH 9/9] fix(proxy): preserve OAuth Live affinity across token refresh --- app/modules/proxy/realtime_auth.py | 15 ++- docs/live-voice.md | 6 +- docs/troubleshooting.md | 2 +- .../add-oauth-live-voice-auth/context.md | 2 +- .../add-oauth-live-voice-auth/design.md | 14 +- .../add-oauth-live-voice-auth/proposal.md | 2 +- .../specs/realtime-api-compat/spec.md | 17 ++- .../add-oauth-live-voice-auth/tasks.md | 4 +- openspec/specs/realtime-api-compat/context.md | 10 +- openspec/specs/realtime-api-compat/spec.md | 17 ++- tests/integration/test_proxy_realtime_live.py | 125 ++++++++++++++++-- tests/unit/test_realtime_auth.py | 58 ++++++-- 12 files changed, 215 insertions(+), 57 deletions(-) diff --git a/app/modules/proxy/realtime_auth.py b/app/modules/proxy/realtime_auth.py index af24690d5f..1614107dc7 100644 --- a/app/modules/proxy/realtime_auth.py +++ b/app/modules/proxy/realtime_auth.py @@ -21,9 +21,9 @@ RealtimeCallerKind = Literal["api_key", "oauth"] ApiKeyValidator = Callable[[str | None], Awaitable[ApiKeyData]] KeylessOriginValidator = Callable[[HTTPConnection], Awaitable[None]] -AffinityMaterialBuilder = Callable[[str, str], str] +AffinityMaterialBuilder = Callable[[str], str] -_OAUTH_LIVE_AFFINITY_DOMAIN = b"codex-lb/oauth-live-affinity/v1" +_OAUTH_LIVE_AFFINITY_DOMAIN = b"codex-lb/oauth-live-account-affinity/v1" class OAuthLiveNotEnabledError(ProxyAuthError): @@ -102,14 +102,15 @@ async def _validate_keyless_origin(connection: HTTPConnection) -> None: await validate_proxy_api_key_authorization(None, request=connection) -def _oauth_live_affinity_scope_material(access_token: str, chatgpt_account_id: str) -> str: +def _oauth_live_affinity_scope_material(chatgpt_account_id: str) -> str: # Derive a purpose-specific HMAC key from the existing persistent encryption # key. Replicas that can decrypt the same account store therefore derive the - # same affinity material without adding another secret or setting. + # same affinity material without adding another secret or setting. The + # downstream bearer is deliberately excluded so OAuth refresh preserves an + # in-flight call's sideband ownership scope. root_key = get_or_create_key() affinity_key = hmac.digest(root_key, _OAUTH_LIVE_AFFINITY_DOMAIN, hashlib.sha256) - credential_pair = f"{access_token}\0{chatgpt_account_id}".encode() - digest = hmac.new(affinity_key, credential_pair, hashlib.sha256).hexdigest() + digest = hmac.new(affinity_key, chatgpt_account_id.encode(), hashlib.sha256).hexdigest() return f"oauth-local:{digest}" @@ -136,6 +137,6 @@ async def resolve_realtime_caller_scope( allowed_account_ids = await _active_oauth_live_allowed_account_ids() return RealtimeCallerScope.for_oauth( - affinity_scope_material=affinity_material_builder(token, normalized_account_id), + affinity_scope_material=affinity_material_builder(normalized_account_id), allowed_account_ids=allowed_account_ids, ) diff --git a/docs/live-voice.md b/docs/live-voice.md index c143d17cc1..f28ee319ee 100644 --- a/docs/live-voice.md +++ b/docs/live-voice.md @@ -32,7 +32,7 @@ The same private routes accept two caller types: The OAuth lane is available when global proxy API-key authentication is disabled and the request comes from loopback or an existing explicitly allowed raw socket CIDR. Loopback needs no CIDR configuration. Other remote clients use registered Proxy API Keys. -codex-lb derives an opaque caller scope locally from the bearer and normalized `chatgpt-account-id`. It does not call OpenAI usage to authenticate Live requests. The network boundary grants keyless access; the credential pair separates call ownership between admitted clients. +codex-lb derives an opaque caller scope locally from the normalized `chatgpt-account-id` header. A bearer remains required on every Live request, but bearer rotation does not change the scope. codex-lb does not verify the bearer or account header through OpenAI usage. The network boundary grants keyless access; admitted clients using the same account id share one ownership namespace and still need the call id to attach. ## Configure the OAuth Live pool @@ -94,7 +94,7 @@ The current client appends `/realtime/calls` to the WebRTC base and `/realtime?i After call creation succeeds, codex-lb binds the returned call id to the final serving account under the caller scope. Every sideband form recomputes that scope, reloads the exact owner, and confirms the current policy still allows it. -OAuth bearer rotation or encryption-key rotation changes the caller scope. A sideband using changed credentials receives the credential-safe not-found response and the client creates a new call. +OAuth bearer rotation preserves the caller scope when `chatgpt-account-id` remains stable, so sideband can reconnect to the existing call. Changing the account header or encryption key changes the scope; sideband then receives the credential-safe not-found response. ## Privacy and request history @@ -105,7 +105,7 @@ Ownership records contain a caller-scoped digest and the owning account referenc - `401 invalid_api_key`: caller authentication or zero-key origin admission failed. - `403 oauth_live_not_enabled`: the global OAuth Live policy is inactive or has no active eligible account. - `400 invalid_realtime_call_id`: the sideband supplied an invalid call id. -- `404 realtime_call_not_found`: ownership is missing or the credential pair changed. +- `404 realtime_call_not_found`: ownership is missing or the account scope changed. - `503 realtime_call_binding_failed`: a successful upstream call could not be bound safely. For client-side symptoms and route checks, see [Troubleshooting](troubleshooting.md#live-voice). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 136b1a90a2..1fdfedf762 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -34,7 +34,7 @@ The OAuth lane uses the zero-key origin boundary and is available while global P Open **Settings → Live Voice**, select at least one active upstream Account, enable **OAuth Live access**, and save. **A sideband returns `404 realtime_call_not_found`.** -The ownership binding is missing or the OAuth credential pair changed. Start a new Live call with the current credentials. +The ownership binding is missing, expired, or scoped to another `chatgpt-account-id`. OAuth bearer refresh preserves the binding when the account header remains stable. Start a new Live call after an account-scope change. ## Fast Mode and service tiers diff --git a/openspec/changes/add-oauth-live-voice-auth/context.md b/openspec/changes/add-oauth-live-voice-auth/context.md index 8514f8922d..ec094c960f 100644 --- a/openspec/changes/add-oauth-live-voice-auth/context.md +++ b/openspec/changes/add-oauth-live-voice-auth/context.md @@ -13,7 +13,7 @@ The registered-Key profile combines `requires_openai_auth = true` with `env_key 1. An operator imports several upstream ChatGPT accounts through existing Codex-LB flows. 2. Under Settings → Live Voice, the operator enables OAuth Live Voice and selects the shared upstream Accounts allowed to serve keyless calls. 3. Official Codex keeps `model_provider = "openai"` and routes WebRTC call creation to `http://127.0.0.1:2455/backend-api/codex` and sideband to `http://127.0.0.1:2455/v1`. -4. Codex-LB applies the existing zero-key origin check, derives a private credential-pair affinity digest, selects within the configured pool, binds the final call owner, and attaches sideband to that owner. +4. Codex-LB applies the existing zero-key origin check, derives a private account-stable affinity digest, selects within the configured pool, binds the final call owner, and attaches sideband to that owner across OAuth bearer refreshes. 5. Registered Proxy API Key clients keep their existing assignments, limits, logs, affinity digests, and Key profile. ## Operational boundary diff --git a/openspec/changes/add-oauth-live-voice-auth/design.md b/openspec/changes/add-oauth-live-voice-auth/design.md index af06bc3cfc..1d8e7055c7 100644 --- a/openspec/changes/add-oauth-live-voice-auth/design.md +++ b/openspec/changes/add-oauth-live-voice-auth/design.md @@ -23,28 +23,32 @@ Ordinary proxy traffic already has a zero-key admission contract. When global AP The keyless lane calls the same proxy authorization dependency with no Proxy Key. Global API-key mode therefore fails closed. Disabled mode retains the existing loopback, trusted-proxy consensus, raw-socket capture, and `proxy_unauthenticated_client_cidrs` checks. -The network boundary supplies trust. The OAuth credential pair supplies call ownership and separation between admitted clients. +The network boundary supplies trust. The normalized, locally unverified `chatgpt-account-id` header supplies a rotation-stable ownership namespace for admitted clients; it is not an independent authentication factor. ### Derive credential-safe affinity locally A purpose-specific HMAC key is derived from the existing persistent encryption key. The caller scope is: -`oauth-local:HMAC-SHA256(derived-key, bearer + NUL + normalized-chatgpt-account-id)` +`oauth-local:HMAC-SHA256(derived-key, normalized-chatgpt-account-id)` Only this digest participates in the existing call-owner digest. Raw bearers, account headers, call ids, SDP, attestation values, frames, audio, and transcripts remain outside persistence and diagnostics. Replicas sharing the existing encryption key derive the same scope without another setting. -A refreshed bearer produces a new caller scope. A sideband using a different bearer cannot attach an older call; the client creates a new call after credential rotation. This preserves possession-based ownership without an upstream identity request. +Bearer rotation preserves the caller scope when the normalized `chatgpt-account-id` remains stable, so an established call can reconnect after OAuth refresh. Changing the normalized account id or persistent encryption key produces a different scope. Every OAuth request still requires a bearer, while the existing zero-key origin boundary remains the authorization decision. + +All admitted clients presenting the same normalized account id share this ownership namespace. Sideband attachment still requires the bounded high-entropy call id, and operators requiring caller-level isolation use registered Proxy API Keys. ### Use one global policy `oauth_live_global_policy` contains singleton id `1`, active state, and timestamps. `oauth_live_global_policy_accounts` links it to imported serving Accounts. Dashboard writes replace the complete set transactionally. Active policy writes require a non-empty known set; runtime lookup filters to currently active Accounts. -Every admitted keyless caller shares this pool. Each credential pair receives isolated affinity material, so learning another call id does not grant sideband attachment. +Every admitted keyless caller shares this pool. Each normalized account id receives isolated affinity material; clients using the same account id share that namespace and still require the call id to attach. ### Keep Key and keyless lanes compatible Key callers keep `api_key.id` affinity, assignments, limits, reservations, last-used updates, and request-log attribution. Keyless callers select within the global pool and write request logs with `api_key_id = NULL`. +Keyless sideband connections still acquire the normal Account stream lease, count toward pool inflight, and obey the serving Account's stream-capacity limit. API-key-specific fair-share admission applies only to registered Key callers; the keyless lane does not synthesize an API Key principal. + ### Keep the Settings job singular The Live Voice card answers one operator question: which upstream Accounts may serve locally admitted keyless Live calls? It exposes one global enable switch, one explicit account multi-select, and one save action. The compact selector keeps selected unavailable Accounts visible so operators can identify and remove stale assignments while other unavailable Accounts remain excluded. @@ -56,6 +60,6 @@ One revision creates the global singleton policy table and its allowed-account r ## Risks - Every process admitted by the existing zero-key network boundary can use the configured OAuth Live pool, matching ordinary zero-key proxy access. -- Bearer rotation changes affinity and requires a new Live call. +- Admitted clients using the same normalized account id share one keyless ownership namespace. - Incorrect trusted-proxy configuration can alter locality decisions, so the existing raw-peer and forwarded-header regression suite remains part of acceptance. - Experimental client route keys can drift, so each bundled Codex upgrade requires call-create, sideband, and audible Live E2E acceptance. diff --git a/openspec/changes/add-oauth-live-voice-auth/proposal.md b/openspec/changes/add-oauth-live-voice-auth/proposal.md index 0543398555..5ead94baa6 100644 --- a/openspec/changes/add-oauth-live-voice-auth/proposal.md +++ b/openspec/changes/add-oauth-live-voice-auth/proposal.md @@ -7,7 +7,7 @@ Official Codex uses ChatGPT OAuth for both WebRTC call creation and the sideband - Accept registered Proxy API Keys and locally admitted ChatGPT OAuth credentials on the four private Live Voice routes. - Dispatch `sk-clb-` bearers through existing strict Key validation. - Admit other bearers only through the existing zero-key proxy origin contract: loopback or an explicitly configured raw-socket CIDR while global API-key authentication is disabled. -- Derive credential-safe caller affinity from a purpose-separated HMAC of the bearer and normalized `chatgpt-account-id`, using the existing persistent encryption key. +- Derive rotation-stable, credential-safe caller affinity from a purpose-separated HMAC of the normalized `chatgpt-account-id`, using the existing persistent encryption key. - Add one global OAuth Live policy with an explicit upstream account pool. - Preserve Key assignments, limits, attribution, affinity input, and the documented `requires_openai_auth = true` plus `env_key` Codex profile. - Add one compact Settings editor: global enable switch, shared upstream pool, and save action. diff --git a/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md index 64587d0cab..64f0ea9078 100644 --- a/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md +++ b/openspec/changes/add-oauth-live-voice-auth/specs/realtime-api-compat/spec.md @@ -2,7 +2,7 @@ ### Requirement: Call creation binds the final account under an admitted caller scope -`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a normalized `chatgpt-account-id` and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, bearer + NUL + normalized-account-id)` as scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a bearer, a normalized `chatgpt-account-id`, and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, normalized-account-id)` as rotation-stable scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. #### Scenario: initial or replacement account creates the call @@ -28,7 +28,7 @@ - **GIVEN** global API-key authentication is disabled, the request passes the existing zero-key origin guard, and the global policy has eligible serving Accounts - **WHEN** call creation succeeds -- **THEN** the final serving Account binds under the credential-pair HMAC scope +- **THEN** the final serving Account binds under the account-stable HMAC scope - **AND** request logging uses nullable API-key attribution #### Scenario: remote OAuth caller is outside the zero-key boundary @@ -74,7 +74,7 @@ ### Requirement: Every sideband route uses the exact bound owner without refresh or failover -The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the credential-pair HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the account-stable HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. #### Scenario: returned Location joins through every supported ingress @@ -102,11 +102,16 @@ The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id} - **THEN** attachment fails closed without revealing or substituting the owner - **AND** it neither refreshes credentials nor selects another account -#### Scenario: keyless credential changes during a call +#### Scenario: keyless bearer rotates during a call -- **WHEN** sideband supplies a different bearer or normalized `chatgpt-account-id` from call creation +- **GIVEN** call creation used one bearer and a normalized `chatgpt-account-id` +- **WHEN** sideband supplies a refreshed bearer with the same normalized `chatgpt-account-id` +- **THEN** it resolves the same ownership digest and attaches to the immutable owner + +#### Scenario: keyless account scope changes during a call + +- **WHEN** sideband supplies a different normalized `chatgpt-account-id` from call creation - **THEN** it resolves a different ownership digest and returns the credential-safe not-found response -- **AND** the client creates a new call after OAuth credential rotation #### Scenario: refreshed call owner attaches with current identity diff --git a/openspec/changes/add-oauth-live-voice-auth/tasks.md b/openspec/changes/add-oauth-live-voice-auth/tasks.md index 0377d40a0c..bc2d10f043 100644 --- a/openspec/changes/add-oauth-live-voice-auth/tasks.md +++ b/openspec/changes/add-oauth-live-voice-auth/tasks.md @@ -13,6 +13,6 @@ ## 3. Verification and delivery -- [x] 3.1 Cover origin admission, credential affinity, policy validation, migration round-trip, HTTP/three-WS auth, owner continuity, nullable logging, and Key compatibility. -- [x] 3.2 Pass real local Codex Desktop normal conversation and audible Live Voice with official OAuth, plus registered-Key regression acceptance. +- [x] 3.1 Cover origin admission, account-stable affinity across bearer rotation, policy validation, migration round-trip, HTTP/three-WS auth, owner continuity, nullable logging, and Key compatibility. +- [x] 3.2 Pass real local Codex Desktop normal conversation and audible Live Voice with official OAuth, including sideband Bearer replacement after call creation, plus registered-Key regression acceptance. - [x] 3.3 Sync stable requirements to main specs/context and pass the local verification gates. diff --git a/openspec/specs/realtime-api-compat/context.md b/openspec/specs/realtime-api-compat/context.md index 59efc66fe2..601a61fa26 100644 --- a/openspec/specs/realtime-api-compat/context.md +++ b/openspec/specs/realtime-api-compat/context.md @@ -19,10 +19,11 @@ The built-in profile preserves the official OpenAI provider and requires no clie - **Bearer dispatch is explicit:** `sk-clb-` bearers enter strict Proxy Key validation. Every other bearer enters the keyless lane. - **Zero-key origin admission is shared:** Keyless Live calls reuse the ordinary proxy's loopback, trusted-proxy consensus, preserved raw-socket peer, and existing unauthenticated CIDR checks. Global API-key mode closes this lane. -- **Possession defines caller scope:** A purpose-separated HMAC of the bearer and normalized account header participates in the ownership digest. A call id alone grants no access. +- **The normalized account header defines the keyless scope:** A purpose-separated HMAC of this locally unverified value participates in the ownership digest. Bearer rotation preserves the scope, while sideband attachment still requires the call id; the network boundary remains the authorization decision. - **Final success owns the call:** Ownership is captured after the final successful account returns a supported call `Location`. - **Ownership is durable and opaque:** The sticky-session store holds a bounded digest and owner reference. Raw call ids, credentials, SDP, attestation values, and frames remain outside persistence. - **Attachment enforces hard continuity:** Every ingress recomputes caller scope, resolves the exact owner, rechecks policy and account state, loads current persisted upstream identity, and acquires one stream lease. +- **Capacity remains account-scoped:** Keyless sideband uses `api_key_id = NULL`, while still counting toward pool inflight and the serving Account's stream-capacity limit. API-key-specific fair-share admission remains exclusive to registered Key callers. - **Protocols stay explicit:** Current-app and v3 ingress connect to `/v1/live/{call_id}`. Legacy ingress preserves remaining ordered query fields and appends one normalized `call_id` to `/v1/realtime`. - **Base setup remains zero-config:** OAuth Live starts disabled. Registered-Key Live keeps its existing configuration and behavior. @@ -41,7 +42,8 @@ The built-in profile preserves the official OpenAI provider and requires no clie - **Source outside the zero-key boundary or global API-key mode enabled:** Deny the keyless lane with `401 invalid_api_key`. - **OAuth policy inactive or empty:** Deny keyless Live before account selection with `403 oauth_live_not_enabled`. -- **Bearer, account header, or encryption key changed:** The ownership namespace changes; an existing sideband receives the credential-safe not-found response and the client creates a new call. +- **Bearer changed with the same account header:** The ownership namespace remains stable and sideband can reconnect to the bound owner. +- **Account header or encryption key changed:** The ownership namespace changes; an existing sideband receives the credential-safe not-found response. - **Missing or unsupported successful `Location` or durable binding failure:** Persist one private error request row and return `503 realtime_call_binding_failed`. - **Conflicting immutable owner:** Preserve the original owner and fail closed. - **Expired ownership or owner outside the current caller scope:** Deny attachment without account substitution. @@ -53,9 +55,9 @@ The built-in profile preserves the official OpenAI provider and requires no clie ### Built-in OAuth profile 1. Codex uses `model_provider = "openai"` and sends official ChatGPT OAuth credentials from loopback. -2. codex-lb applies its existing zero-key origin guard and derives an opaque credential-pair scope locally. +2. codex-lb applies its existing zero-key origin guard and derives an opaque account-stable scope locally. 3. The final serving Account owns the new call under that scope. -4. Sideband presents the same credential pair and reconnects to that exact owner. +4. Sideband presents the same account identity with the current bearer and reconnects to that exact owner. ### Registered-Key profile diff --git a/openspec/specs/realtime-api-compat/spec.md b/openspec/specs/realtime-api-compat/spec.md index 5d40d3f65c..dbdd13c86b 100644 --- a/openspec/specs/realtime-api-compat/spec.md +++ b/openspec/specs/realtime-api-compat/spec.md @@ -8,7 +8,7 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro ### Requirement: Call creation binds the final account under an admitted caller scope -`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a normalized `chatgpt-account-id` and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, bearer + NUL + normalized-account-id)` as scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. +`POST /backend-api/codex/realtime/calls` SHALL require either a registered Proxy API Key or a locally admitted keyless OAuth caller. A `sk-clb-` bearer SHALL use strict Key validation. Every other bearer SHALL pass the ordinary zero-key proxy origin contract before policy lookup: global API-key authentication is disabled, and the connection is loopback or its raw socket peer belongs to the existing explicit unauthenticated CIDR allowlist. Keyless OAuth SHALL require a bearer, a normalized `chatgpt-account-id`, and an active global policy with at least one currently active allowed Account. After a successful upstream response with a root-relative or absolute `Location` whose parsed path is exactly `/v1/realtime/calls/{call_id}`, where `{call_id}` is a bounded ASCII `rtc_...` or canonical UUID, the proxy MUST bind the call immutably to the final ChatGPT account that completed the request. Relative paths without the leading `/`, unrelated path prefixes, abbreviated `/live/...` or `/realtime/calls/...` paths, and paths with extra segments are unsupported. The binding MUST be scoped to the caller, MUST persist across replicas as only a bounded digest in a reserved non-user-forgeable namespace, MUST expire after a fixed interval, and MUST NOT persist the raw call id, API key, OAuth token, account header, SDP, attestation value, or frame body. Key callers retain `SHA256(api_key.id + NUL + call_id)`. Keyless callers use `oauth-local:HMAC-SHA256(K, normalized-account-id)` as rotation-stable scope material, where `K` is purpose-separated from the existing persistent encryption key. Private call-creation diagnostics MUST redact internal account identifiers and suppress exception details. #### Scenario: initial or replacement account creates the call @@ -34,7 +34,7 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro - **GIVEN** global API-key authentication is disabled, the request passes the existing zero-key origin guard, and the global policy has eligible serving Accounts - **WHEN** call creation succeeds -- **THEN** the final serving Account binds under the credential-pair HMAC scope +- **THEN** the final serving Account binds under the account-stable HMAC scope - **AND** request logging uses nullable API-key attribution #### Scenario: remote OAuth caller is outside the zero-key boundary @@ -80,7 +80,7 @@ Define private Codex Live Voice call-owner continuity, authenticated sideband ro ### Requirement: Every sideband route uses the exact bound owner without refresh or failover -The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the credential-pair HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. +The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id}`, and `WS /v1/realtime?call_id={call_id}` to registered Proxy API Keys and locally admitted keyless OAuth callers. All three routes SHALL use the same caller resolver and zero-key origin guard as call creation. All adapters MUST use one bounded call-id normalizer, current caller policy check, exact-owner selection, fresh owner load, reattach stream lease, relay, and connector service. Keyless sideband SHALL recompute the account-stable HMAC and confirm that the immutable owner remains in the current global allowed set. Current-app and v3 ingress MUST reject any downstream `call_id` query parameter before entering the live service or connector and MUST NOT reconcile it with the path id. Current-app and v3 ingress MUST connect upstream to `/v1/live/{call_id}`. Legacy ingress MUST consume exactly one downstream `call_id` and append its normalized value once after remaining ordered query pairs to `/v1/realtime`. #### Scenario: returned Location joins through every supported ingress @@ -108,11 +108,16 @@ The proxy SHALL expose `WS /backend-api/codex/{call_id}`, `WS /v1/live/{call_id} - **THEN** attachment fails closed without revealing or substituting the owner - **AND** it neither refreshes credentials nor selects another account -#### Scenario: keyless credential changes during a call +#### Scenario: keyless bearer rotates during a call -- **WHEN** sideband supplies a different bearer or normalized `chatgpt-account-id` from call creation +- **GIVEN** call creation used one bearer and a normalized `chatgpt-account-id` +- **WHEN** sideband supplies a refreshed bearer with the same normalized `chatgpt-account-id` +- **THEN** it resolves the same ownership digest and attaches to the immutable owner + +#### Scenario: keyless account scope changes during a call + +- **WHEN** sideband supplies a different normalized `chatgpt-account-id` from call creation - **THEN** it resolves a different ownership digest and returns the credential-safe not-found response -- **AND** the client creates a new call after OAuth credential rotation #### Scenario: refreshed call owner attaches with current identity diff --git a/tests/integration/test_proxy_realtime_live.py b/tests/integration/test_proxy_realtime_live.py index 498ae21cb9..811c5dc54e 100644 --- a/tests/integration/test_proxy_realtime_live.py +++ b/tests/integration/test_proxy_realtime_live.py @@ -261,21 +261,23 @@ async def fake_proxy_live( base_url="http://localhost", client=("127.0.0.1", 50000), ) as client: - with client.websocket_connect( - "ws://localhost/v1/live/rtc_local_oauth", - headers={ - "Authorization": "Bearer oauth-token", - "chatgpt-account-id": "workspace-caller", - }, - ) as websocket: - assert websocket.receive_text() == "ready" + for token in ("oauth-token-before-refresh", "oauth-token-after-refresh"): + with client.websocket_connect( + "ws://localhost/v1/live/rtc_local_oauth", + headers={ + "Authorization": f"Bearer {token}", + "chatgpt-account-id": "workspace-caller", + }, + ) as websocket: + assert websocket.receive_text() == "ready" - assert len(captured_scopes) == 1 + assert len(captured_scopes) == 2 scope = captured_scopes[0] assert scope.kind == "oauth" assert scope.api_key is None assert scope.allowed_account_ids == frozenset({allowed_account_id}) assert scope.affinity_scope_material.startswith("oauth-local:") + assert captured_scopes[1].affinity_scope_material == scope.affinity_scope_material @pytest.mark.parametrize( @@ -416,6 +418,111 @@ async def capture_request_log(**kwargs: object) -> None: assert persisted.api_key_id is None +@pytest.mark.asyncio +async def test_oauth_live_sideband_reconnects_after_downstream_bearer_rotation( + app_instance, + async_client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + imported = await async_client.post( + "/api/accounts/import", + files={ + "auth_json": ( + "allowed.json", + json.dumps(_auth_json("workspace-allowed", "allowed@example.com")), + "application/json", + ) + }, + ) + assert imported.status_code == 200 + policy = await async_client.put( + "/api/oauth-live-policy", + json={"isActive": True, "allowedAccountIds": [imported.json()["accountId"]]}, + ) + assert policy.status_code == 200 + + async def create_call(*_args: object, **_kwargs: object) -> CodexControlResponse: + return CodexControlResponse( + status_code=201, + body=b"answer", + headers={"location": "/v1/realtime/calls/rtc_oauth_rotated"}, + ) + + class Upstream: + uses_proxy = False + + def __init__(self) -> None: + self.messages = [ + UpstreamWebSocketMessage(kind="text", text="ready"), + UpstreamWebSocketMessage(kind="close", close_code=1000, close_reason="done"), + ] + + async def send_text(self, _text: str) -> None: + return None + + async def send_bytes(self, _data: bytes) -> None: + return None + + async def receive(self) -> UpstreamWebSocketMessage: + return self.messages.pop(0) + + async def close(self, code: int = 1000, reason: str = "") -> None: + del code, reason + + def response_header(self, _name: str) -> str | None: + return None + + def archive_received(self, _message: UpstreamWebSocketMessage) -> None: + return None + + connector_calls: list[tuple[str, str | None]] = [] + + async def connect_upstream( + _headers: dict[str, str], + access_token: str, + account_id: str | None, + **_kwargs: object, + ) -> Upstream: + connector_calls.append((access_token, account_id)) + return Upstream() + + monkeypatch.setattr(proxy_module, "core_codex_control_request", create_call) + monkeypatch.setattr(proxy_websocket_module, "_connect_upstream_websocket", connect_upstream) + + created = await async_client.post( + "/backend-api/codex/realtime/calls", + content=b"offer", + headers={ + "Authorization": "Bearer oauth-token-before-refresh", + "chatgpt-account-id": "workspace-caller", + }, + ) + assert created.status_code == 201 + service = get_proxy_service_for_app(app_instance) + assert await service.drain_persistence_tasks(timeout_seconds=1) + + with TestClient( + app_instance, + base_url="http://localhost", + client=("127.0.0.1", 50000), + ) as client: + with client.websocket_connect( + "ws://localhost/v1/live/rtc_oauth_rotated", + headers={ + "Authorization": "Bearer oauth-token-after-refresh", + "chatgpt-account-id": "workspace-caller", + }, + ) as websocket: + assert websocket.receive_text() == "ready" + close_message = websocket.receive() + assert close_message["type"] == "websocket.close" + assert close_message["code"] == 1000 + assert client.portal is not None + assert client.portal.call(lambda: service.drain_persistence_tasks(timeout_seconds=1)) + + assert connector_calls == [("access-token", "workspace-allowed")] + + @pytest.mark.asyncio async def test_realtime_call_create_rejects_remote_oauth_before_account_selection( async_client, diff --git a/tests/unit/test_realtime_auth.py b/tests/unit/test_realtime_auth.py index d48140108f..59d405f8f7 100644 --- a/tests/unit/test_realtime_auth.py +++ b/tests/unit/test_realtime_auth.py @@ -166,12 +166,12 @@ async def validate_key(_authorization: str | None) -> ApiKeyData: @pytest.mark.asyncio -async def test_oauth_scope_uses_keyless_origin_and_credential_affinity( +async def test_oauth_scope_uses_keyless_origin_and_account_affinity( monkeypatch: pytest.MonkeyPatch, ) -> None: connection = _connection() origin_connections: list[HTTPConnection] = [] - affinity_inputs: list[tuple[str, str]] = [] + affinity_inputs: list[str] = [] async def validate_origin(candidate: HTTPConnection) -> None: origin_connections.append(candidate) @@ -179,8 +179,8 @@ async def validate_origin(candidate: HTTPConnection) -> None: async def load_policy() -> frozenset[str]: return frozenset({"allowed-a", "allowed-b"}) - def build_affinity(token: str, account_id: str) -> str: - affinity_inputs.append((token, account_id)) + def build_affinity(account_id: str) -> str: + affinity_inputs.append(account_id) return "oauth-local:digest" monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) @@ -194,7 +194,7 @@ def build_affinity(token: str, account_id: str) -> str: ) assert origin_connections == [connection] - assert affinity_inputs == [("oauth-token", "workspace-id")] + assert affinity_inputs == ["workspace-id"] assert scope.kind == "oauth" assert scope.affinity_scope_material == "oauth-local:digest" assert scope.api_key is None @@ -279,19 +279,53 @@ async def load_policy() -> frozenset[str]: assert raised.value.code == "oauth_live_not_enabled" -def test_oauth_affinity_is_deterministic_credential_bound_and_secret_free( +def test_oauth_affinity_is_deterministic_account_bound_and_secret_free( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(realtime_auth_module, "get_or_create_key", lambda: b"persistent-test-key") - first = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-a") - repeated = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-a") - rotated = realtime_auth_module._oauth_live_affinity_scope_material("token-b", "account-a") - other_account = realtime_auth_module._oauth_live_affinity_scope_material("token-a", "account-b") + first = realtime_auth_module._oauth_live_affinity_scope_material("account-a") + repeated = realtime_auth_module._oauth_live_affinity_scope_material("account-a") + other_account = realtime_auth_module._oauth_live_affinity_scope_material("account-b") assert first == repeated assert first.startswith("oauth-local:") assert len(first) == len("oauth-local:") + 64 - assert len({first, rotated, other_account}) == 3 - assert "token-a" not in first + assert first != other_account assert "account-a" not in first + + +@pytest.mark.asyncio +async def test_oauth_scope_survives_bearer_rotation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def allow_origin(_connection: HTTPConnection) -> None: + return None + + async def load_policy() -> frozenset[str]: + return frozenset({"allowed-a"}) + + monkeypatch.setattr(realtime_auth_module, "get_or_create_key", lambda: b"persistent-test-key") + monkeypatch.setattr(realtime_auth_module, "_active_oauth_live_allowed_account_ids", load_policy) + + before_refresh = await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), + "Bearer oauth-token-a", + "workspace-id", + keyless_origin_validator=allow_origin, + ) + after_refresh = await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), + "Bearer oauth-token-b", + "workspace-id", + keyless_origin_validator=allow_origin, + ) + other_account = await realtime_auth_module.resolve_realtime_caller_scope( + _connection(), + "Bearer oauth-token-b", + "other-workspace-id", + keyless_origin_validator=allow_origin, + ) + + assert before_refresh.affinity_scope_material == after_refresh.affinity_scope_material + assert before_refresh.affinity_scope_material != other_account.affinity_scope_material