diff --git a/coding-agent/docs/adr/0050-codex-oauth-provider.md b/coding-agent/docs/adr/0050-codex-oauth-provider.md new file mode 100644 index 000000000..f126a2899 --- /dev/null +++ b/coding-agent/docs/adr/0050-codex-oauth-provider.md @@ -0,0 +1,105 @@ +# ADR-0050: Codex OAuth Provider Support + +**Status**: Accepted +**Date**: 2026-05-31 + +## Context + +The coding agent currently supports API-key-based LLM providers (OpenAI, Anthropic, Copilot, Kimi, DeepSeek, StepFun). We need to add support for OpenAI's ChatGPT Codex Responses API, which requires OAuth 2.0 device authorization instead of an API key. Codex provides a distinct API surface and model capabilities that differ from the standard chat completions API. + +## Decision + +We will implement **Codex OAuth support** directly within the coding agent codebase under a new `src/coding_agent/oauth/` package, rather than depending on the `ya-oauth` mono-repo packages. + +### Why not use `ya-oauth` / `ya-oauth-provider`? + +The reference `ya-oauth` packages provide a clean OAuth client, but pulling in the entire mono-repo introduces a larger dependency surface than needed. The core OAuth flow (device code → token exchange → refresh) is straightforward and can be implemented in a self-contained module. + +### Architecture + +``` +src/coding_agent/oauth/ +├── __init__.py # Public exports +├── types.py # Pydantic models +├── store.py # File-backed OAuth credential store +├── codex.py # Codex OAuth client (device flow, token exchange, refresh) +├── auth.py # httpx OAuthBearerAuth class +└── cli.py # Click CLI commands (login, status, refresh, logout, doctor) +``` + +### OAuth Flow + +1. **Device Code Request**: POST to `https://auth.openai.com/api/accounts/deviceauth/usercode` +2. **User Authorization**: User opens `https://auth.openai.com/codex/device` in browser and enters the code +3. **Token Polling**: POST to device token endpoint until user authorizes +4. **Token Exchange**: Exchange authorization code for access/refresh tokens +5. **Token Refresh**: Use refresh token to obtain new access token before it expires +6. **Storage**: Tokens stored in `~/.coding-agent/oauth/auth.json` with file locking + +### Provider Integration + +The Codex provider wraps `OpenAICompatProvider` with `OAuthBearerAuth`, an `httpx.Auth` subclass that: + +1. Reads the stored access token +2. Attaches it as a `Bearer` authorization header +3. Automatically refreshes the token on 401 responses +4. Persists the refreshed token to the auth store + +### CLI Integration + +A new `coding-agent oauth` command group is added: + +```bash +coding-agent oauth login codex # Start device code flow +coding-agent oauth status # Show login status +coding-agent oauth refresh codex # Manually refresh tokens +coding-agent oauth logout codex # Revoke and remove tokens +coding-agent oauth doctor # Inspect auth file health +``` + +The `coding-agent --provider codex` flag selects the Codex provider for agent runs. + +## Consequences + +### Positive + +- Self-contained OAuth implementation with minimal dependencies (httpx, pydantic) +- Reuses existing `OpenAICompatProvider` streaming infrastructure +- Consistent UX with `ya-oauth` reference implementation +- Secure file storage with proper permissions (0600 for files, 0700 for directories) + +### Negative + +- Duplicates some logic from `ya-oauth` (JWT parsing, device auth polling) +- Must maintain the OAuth flow if OpenAI changes their endpoints + +### Risks + +- OpenAI may change the Codex OAuth endpoints or flow (mitigated by keeping it self-contained and easy to update) +- Token refresh failures could interrupt long-running agent sessions (mitigated by retry logic in the provider) + +## Alternatives Rejected + +1. **Depend on `ya-oauth` packages** — Rejected to keep dependency surface small +2. **API key fallback** — Codex does not support API key authentication; OAuth is required +3. **External credential helper** — Rejected to keep the UX simple (single binary) + +## Acceptance Criteria + +- [ ] `test_oauth_store_writes_private_file_and_round_trips_record` +- [ ] `test_store_backed_token_source_refreshes_and_persists_token` +- [ ] `test_store_backed_token_source_missing_login_fails_fast` +- [ ] `test_oauth_bearer_auth_refreshes_on_unauthorized_response` +- [ ] `test_oauth_doctor_redacts_tokens` +- [ ] `test_codex_provider_uses_oauth_backed_openai_compat` +- [ ] `uv run pytest tests/coding_agent/test_codex_oauth.py tests/cli/test_oauth_commands.py tests/coding_agent/plugins/test_llm_provider.py -k "oauth or codex" -v` + +## References + +- `src/coding_agent/oauth/` +- `src/coding_agent/cli/oauth_commands.py` +- `src/coding_agent/plugins/llm_provider.py` +- `tests/coding_agent/test_codex_oauth.py` +- `tests/cli/test_oauth_commands.py` +- `https://github.com/Wh1isper/ya-mono/tree/main/packages/ya-oauth` +- `https://github.com/Wh1isper/ya-mono/tree/main/packages/ya-oauth-provider` diff --git a/coding-agent/src/coding_agent/cli/main.py b/coding-agent/src/coding_agent/cli/main.py index 13069c7aa..429abf85f 100644 --- a/coding-agent/src/coding_agent/cli/main.py +++ b/coding-agent/src/coding_agent/cli/main.py @@ -10,6 +10,7 @@ from coding_agent.adapter import PipelineAdapter from coding_agent.app import create_agent, create_child_pipeline # noqa: F401 from coding_agent.cli.kb_commands import kb +from coding_agent.cli.oauth_commands import oauth_cli from coding_agent.cli.postmortem_commands import postmortem from coding_agent.cli.remote_commands import attach, remote from coding_agent.cli.serve_command import serve @@ -230,6 +231,7 @@ def _create_provider(config): main.add_command(kb) +main.add_command(oauth_cli) main.add_command(postmortem) main.add_command(stats) main.add_command(serve) diff --git a/coding-agent/src/coding_agent/cli/oauth_commands.py b/coding-agent/src/coding_agent/cli/oauth_commands.py new file mode 100644 index 000000000..e2051cdd9 --- /dev/null +++ b/coding-agent/src/coding_agent/cli/oauth_commands.py @@ -0,0 +1,5 @@ +"""Re-export OAuth CLI commands for integration with the main CLI.""" + +from coding_agent.oauth.cli import oauth_cli + +__all__ = ["oauth_cli"] diff --git a/coding-agent/src/coding_agent/core/config.py b/coding-agent/src/coding_agent/core/config.py index 4d4a4100d..b253a85a6 100644 --- a/coding-agent/src/coding_agent/core/config.py +++ b/coding-agent/src/coding_agent/core/config.py @@ -17,6 +17,7 @@ "kimi-code-anthropic", "deepseek", "stepfun", + "codex", ] diff --git a/coding-agent/src/coding_agent/oauth/__init__.py b/coding-agent/src/coding_agent/oauth/__init__.py new file mode 100644 index 000000000..d8aeee264 --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/__init__.py @@ -0,0 +1,22 @@ +"""OAuth credential management for coding agent providers.""" + +from coding_agent.oauth.codex import CodexOAuthClient, create_codex_token_source +from coding_agent.oauth.store import OAuthStore +from coding_agent.oauth.types import ( + AuthFile, + OAuthAccount, + OAuthProviderRecord, + OAuthTokens, + TokenSnapshot, +) + +__all__ = [ + "AuthFile", + "CodexOAuthClient", + "OAuthAccount", + "OAuthProviderRecord", + "OAuthStore", + "OAuthTokens", + "TokenSnapshot", + "create_codex_token_source", +] diff --git a/coding-agent/src/coding_agent/oauth/auth.py b/coding-agent/src/coding_agent/oauth/auth.py new file mode 100644 index 000000000..44e05858e --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/auth.py @@ -0,0 +1,80 @@ +"""httpx Auth handler for OAuth Bearer tokens with auto-refresh.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncGenerator, Generator + +import httpx + +from coding_agent.oauth.types import OAuthTokenSource + +logger = logging.getLogger(__name__) + + +class OAuthBearerAuth(httpx.Auth): + """httpx Auth that injects an OAuth Bearer token and auto-refreshes on 401. + + This is designed for use with OpenAI-compatible providers that require + OAuth tokens instead of API keys. + """ + + def __init__( + self, + token_source: OAuthTokenSource, + *, + provider_name: str = "codex", + extra_headers: dict[str, str] | None = None, + ) -> None: + self._token_source = token_source + self._provider_name = provider_name + self._extra_headers = extra_headers + self._cached_token: str | None = None + + def sync_auth_flow( + self, request: httpx.Request + ) -> Generator[httpx.Request, httpx.Response, None]: + """Inject a bearer token for synchronous httpx clients.""" + import asyncio + + snapshot = asyncio.run(self._token_source.get_token()) + self._cached_token = snapshot.access_token + request.headers["Authorization"] = f"Bearer {snapshot.access_token}" + if self._extra_headers: + for key, value in self._extra_headers.items(): + request.headers.setdefault(key, value) + response = yield request + + if response.status_code == 401: + response.read() + logger.debug( + "OAuth token expired (401), refreshing for %s", + self._provider_name, + ) + snapshot = asyncio.run(self._token_source.refresh_token()) + self._cached_token = snapshot.access_token + request.headers["Authorization"] = f"Bearer {snapshot.access_token}" + yield request + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Inject a bearer token for asynchronous httpx clients.""" + snapshot = await self._token_source.get_token() + self._cached_token = snapshot.access_token + request.headers["Authorization"] = f"Bearer {snapshot.access_token}" + if self._extra_headers: + for key, value in self._extra_headers.items(): + request.headers.setdefault(key, value) + response = yield request + + if response.status_code == 401: + await response.aread() + logger.debug( + "OAuth token expired (401), refreshing for %s", + self._provider_name, + ) + snapshot = await self._token_source.refresh_token() + self._cached_token = snapshot.access_token + request.headers["Authorization"] = f"Bearer {snapshot.access_token}" + yield request diff --git a/coding-agent/src/coding_agent/oauth/cli.py b/coding-agent/src/coding_agent/oauth/cli.py new file mode 100644 index 000000000..86ebc13ae --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/cli.py @@ -0,0 +1,185 @@ +"""CLI commands for OAuth credential management.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import click + +from coding_agent.oauth.codex import CodexOAuthClient, redact_record +from coding_agent.oauth.store import DEFAULT_AUTH_PATH, OAuthStore + + +@click.group(name="oauth") +def oauth_cli() -> None: + """Manage OAuth credentials for coding agent providers.""" + + +@oauth_cli.command() +@click.argument("provider", type=click.Choice(["codex"])) +@click.option( + "--auth-file", + type=click.Path(path_type=Path), + default=None, + help="Auth file path. Defaults to ~/.coding-agent/oauth/auth.json.", +) +def login(provider: str, auth_file: Path | None) -> None: + """Log in to an OAuth provider.""" + store = OAuthStore(auth_file) + if provider == "codex": + client = CodexOAuthClient(store=store) + try: + device_code = client.request_device_code() + click.echo("Open this URL in your browser and sign in to ChatGPT:") + click.echo(device_code.verification_url) + click.echo("") + click.echo("Enter this one-time code:") + click.echo(device_code.user_code) + click.echo("") + click.echo("Waiting for browser authorization...") + token_code = client.poll_device_token(device_code) + record = client.exchange_device_code(token_code) + store.set_provider("codex", record) + email = record.account.email or "unknown account" + click.echo(f"Logged in to codex as {email}.") + finally: + client.close() + + +@oauth_cli.command() +@click.argument("provider", type=click.Choice(["codex"]), required=False) +@click.option( + "--auth-file", + type=click.Path(path_type=Path), + default=None, + help="Auth file path. Defaults to ~/.coding-agent/oauth/auth.json.", +) +def status(provider: str | None, auth_file: Path | None) -> None: + """Show OAuth provider login status.""" + store = OAuthStore(auth_file) + auth = store.load() + provider_names = [provider] if provider else sorted(auth.providers) + if not provider_names: + click.echo("No OAuth providers are logged in.") + return + for provider_name in provider_names: + record = auth.providers.get(provider_name) + if record is None: + click.echo(f"{provider_name}: not logged in") + continue + account = record.account + identity = account.email or account.chatgpt_user_id or "unknown account" + plan = ( + f", plan={account.chatgpt_plan_type}" if account.chatgpt_plan_type else "" + ) + refreshed = ( + record.last_refresh_at.isoformat() if record.last_refresh_at else "never" + ) + click.echo( + f"{provider_name}: logged in as {identity}{plan}, " + f"last_refresh_at={refreshed}" + ) + + +@oauth_cli.command(name="refresh") +@click.argument("provider", type=click.Choice(["codex"])) +@click.option( + "--auth-file", + type=click.Path(path_type=Path), + default=None, + help="Auth file path. Defaults to ~/.coding-agent/oauth/auth.json.", +) +def refresh_cmd(provider: str, auth_file: Path | None) -> None: + """Refresh OAuth credentials.""" + store = OAuthStore(auth_file) + if provider == "codex": + client = CodexOAuthClient(store=store) + try: + source = client.make_token_source() + snapshot = _run_sync(source.refresh_token()) + identity = ( + snapshot.account.email + or snapshot.account.chatgpt_user_id + or "unknown account" + ) + click.echo(f"Refreshed codex credentials for {identity}.") + finally: + client.close() + + +@oauth_cli.command() +@click.argument("provider", type=click.Choice(["codex"])) +@click.option( + "--auth-file", + type=click.Path(path_type=Path), + default=None, + help="Auth file path. Defaults to ~/.coding-agent/oauth/auth.json.", +) +@click.option( + "--revoke/--no-revoke", + default=True, + help="Revoke provider tokens before deleting local credentials.", +) +def logout(provider: str, auth_file: Path | None, revoke: bool) -> None: + """Log out from an OAuth provider.""" + store = OAuthStore(auth_file) + record = store.get_provider(provider) + if record is None: + click.echo(f"{provider}: not logged in") + return + if provider == "codex" and revoke: + client = CodexOAuthClient(store=store) + try: + client.revoke_record(record) + finally: + client.close() + store.delete_provider(provider) + click.echo(f"Logged out from {provider}.") + + +@oauth_cli.command() +@click.option( + "--auth-file", + type=click.Path(path_type=Path), + default=None, + help="Auth file path. Defaults to ~/.coding-agent/oauth/auth.json.", +) +def doctor(auth_file: Path | None) -> None: + """Inspect OAuth store health without printing tokens.""" + path = (auth_file or DEFAULT_AUTH_PATH).expanduser() + store = OAuthStore(path) + auth = store.load() + click.echo(f"Auth file: {path}") + click.echo( + f"Providers: {', '.join(sorted(auth.providers)) if auth.providers else 'none'}" + ) + _print_mode("Directory", path.parent, expected=0o700) + if path.exists(): + _print_mode("File", path, expected=0o600) + for provider_name, record in sorted(auth.providers.items()): + safe_record = redact_record(record) + account = safe_record.get("account", {}) + tokens = safe_record.get("tokens", {}) + click.echo(f"{provider_name}: account={account}, tokens={tokens}") + + +def _print_mode(label: str, path: Path, *, expected: int) -> None: + try: + mode = stat.S_IMODE(os.stat(path).st_mode) + except FileNotFoundError: + click.echo(f"{label} mode: missing (expected {expected:o})") + return + status_text = "ok" if mode == expected else f"expected {expected:o}" + click.echo(f"{label} mode: {mode:o} ({status_text})") + + +def _run_sync(awaitable): # type: ignore[no-untyped-def] + import asyncio + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(awaitable) + raise RuntimeError(f"Cannot run OAuth command inside active event loop: {loop}") diff --git a/coding-agent/src/coding_agent/oauth/codex.py b/coding-agent/src/coding_agent/oauth/codex.py new file mode 100644 index 000000000..d13357c67 --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/codex.py @@ -0,0 +1,335 @@ +"""Codex OAuth device-code login and refresh client.""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +import httpx +from pydantic import AliasChoices, BaseModel, Field + +from coding_agent.oauth.store import OAuthStore, StoreBackedTokenSource +from coding_agent.oauth.types import ( + OAuthAccount, + OAuthProviderRecord, + OAuthTokens, +) + +CODEX_ISSUER = "https://auth.openai.com" +CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CODEX_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token" # noqa: S105 +CODEX_REVOKE_ENDPOINT = "https://auth.openai.com/oauth/revoke" +CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" +CODEX_DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback" +CODEX_SCOPES = [ + "openid", + "profile", + "email", + "offline_access", + "api.connectors.read", + "api.connectors.invoke", +] + + +@dataclass(frozen=True) +class CodexOAuthProfile: + """Codex OAuth endpoint configuration.""" + + issuer: str = CODEX_ISSUER + client_id: str = CODEX_CLIENT_ID + token_endpoint: str = CODEX_TOKEN_ENDPOINT + revoke_endpoint: str = CODEX_REVOKE_ENDPOINT + base_url: str = CODEX_BASE_URL + scopes: tuple[str, ...] = tuple(CODEX_SCOPES) + + @property + def device_user_code_endpoint(self) -> str: + return f"{self.issuer.rstrip('/')}/api/accounts/deviceauth/usercode" + + @property + def device_token_endpoint(self) -> str: + return f"{self.issuer.rstrip('/')}/api/accounts/deviceauth/token" + + @property + def verification_url(self) -> str: + return f"{self.issuer.rstrip('/')}/codex/device" + + @property + def device_redirect_uri(self) -> str: + return f"{self.issuer.rstrip('/')}/deviceauth/callback" + + +CODEX_PROFILE = CodexOAuthProfile() + + +class DeviceCode(BaseModel): + """Device authorization code returned to the user.""" + + verification_url: str + user_code: str + device_auth_id: str + interval: int = 5 + + +class _UserCodeResponse(BaseModel): + device_auth_id: str + user_code: str = Field(validation_alias=AliasChoices("user_code", "usercode")) + interval: int | str = 5 + + def to_device_code(self, profile: CodexOAuthProfile) -> DeviceCode: + return DeviceCode( + verification_url=profile.verification_url, + user_code=self.user_code, + device_auth_id=self.device_auth_id, + interval=int(self.interval), + ) + + +class _DeviceTokenResponse(BaseModel): + authorization_code: str + code_challenge: str + code_verifier: str + + +class _TokenResponse(BaseModel): + id_token: str | None = None + access_token: str | None = None + refresh_token: str | None = None + + +def _decode_jwt_payload(jwt: str) -> dict[str, Any]: + """Decode a JWT payload without signature validation for local metadata extraction.""" + parts = jwt.split(".") + if len(parts) != 3 or not parts[1]: + raise ValueError("invalid JWT format") + payload = parts[1] + payload += "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + data = json.loads(decoded.decode("utf-8")) + if not isinstance(data, dict): + raise TypeError("invalid JWT payload") + return data + + +def _string_or_none(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _plan_type(value: object) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, dict): + raw = value.get("raw_value") or value.get("value") or value.get("name") + return _string_or_none(raw) + return None + + +def _account_from_id_token(id_token: str) -> OAuthAccount: + """Extract ChatGPT account metadata from an ID token.""" + claims = _decode_jwt_payload(id_token) + profile = claims.get("https://api.openai.com/profile") + auth = claims.get("https://api.openai.com/auth") + profile_data = profile if isinstance(profile, dict) else {} + auth_data = auth if isinstance(auth, dict) else {} + return OAuthAccount( + email=_string_or_none(claims.get("email")) + or _string_or_none(profile_data.get("email")), + chatgpt_user_id=_string_or_none(auth_data.get("chatgpt_user_id")) + or _string_or_none(auth_data.get("user_id")), + chatgpt_account_id=_string_or_none(auth_data.get("chatgpt_account_id")), + chatgpt_plan_type=_plan_type(auth_data.get("chatgpt_plan_type")), + chatgpt_account_is_fedramp=bool( + auth_data.get("chatgpt_account_is_fedramp", False) + ), + ) + + +class CodexOAuthClient: + """Codex OAuth device-code login and refresh client.""" + + def __init__( + self, + *, + profile: CodexOAuthProfile = CODEX_PROFILE, + store: OAuthStore | None = None, + http_client: httpx.Client | None = None, + ) -> None: + self.profile = profile + self.store = store or OAuthStore() + self.http_client = http_client or httpx.Client(timeout=30) + self._owns_http_client = http_client is None + + def close(self) -> None: + if self._owns_http_client: + self.http_client.close() + + def request_device_code(self) -> DeviceCode: + """Request a device authorization code from OpenAI.""" + response = self.http_client.post( + self.profile.device_user_code_endpoint, + json={"client_id": self.profile.client_id}, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + return _UserCodeResponse.model_validate(response.json()).to_device_code( + self.profile + ) + + def poll_device_token( + self, device_code: DeviceCode, *, timeout_seconds: int = 15 * 60 + ) -> _DeviceTokenResponse: + """Poll for device authorization until the user completes the flow.""" + monotonic = __import__("time").monotonic + end_at = monotonic() + timeout_seconds + while True: + response = self.http_client.post( + self.profile.device_token_endpoint, + json={ + "device_auth_id": device_code.device_auth_id, + "user_code": device_code.user_code, + }, + headers={"Content-Type": "application/json"}, + ) + if response.is_success: + return _DeviceTokenResponse.model_validate(response.json()) + if response.status_code in (403, 404) and monotonic() < end_at: + sleep_for = min(device_code.interval, max(0.0, end_at - monotonic())) + __import__("time").sleep(sleep_for) + continue + response.raise_for_status() + raise RuntimeError("Codex device authorization failed") + + def exchange_device_code( + self, code_response: _DeviceTokenResponse + ) -> OAuthProviderRecord: + """Exchange device authorization code for tokens.""" + response = self.http_client.post( + self.profile.token_endpoint, + data={ + "grant_type": "authorization_code", + "code": code_response.authorization_code, + "redirect_uri": self.profile.device_redirect_uri, + "client_id": self.profile.client_id, + "code_verifier": code_response.code_verifier, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + token_response = _TokenResponse.model_validate(response.json()) + return self._record_from_token_response(token_response) + + def refresh_record(self, record: OAuthProviderRecord) -> OAuthProviderRecord: + """Refresh an existing OAuth provider record.""" + refresh_token = record.tokens.refresh_token + if not refresh_token: + raise RuntimeError( + "Codex refresh token is missing; run `coding-agent oauth login codex` again." + ) + response = self.http_client.post( + self.profile.token_endpoint, + json={ + "client_id": self.profile.client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + token_response = _TokenResponse.model_validate(response.json()) + account = ( + _account_from_id_token(token_response.id_token) + if token_response.id_token + else record.account + ) + _validate_same_account(record.account, account) + return record.with_refreshed_tokens( + id_token=token_response.id_token, + access_token=token_response.access_token, + refresh_token=token_response.refresh_token, + account=account, + ) + + def revoke_record(self, record: OAuthProviderRecord) -> None: + """Revoke tokens for a provider record.""" + token = record.tokens.refresh_token or record.tokens.access_token + if not token or not self.profile.revoke_endpoint: + return + response = self.http_client.post( + self.profile.revoke_endpoint, + data={"client_id": self.profile.client_id, "token": token}, + ) + if response.status_code < 400: + return + response.raise_for_status() + + def make_token_source(self) -> StoreBackedTokenSource: + """Create an async token source backed by the store.""" + return StoreBackedTokenSource( + "codex", store=self.store, refresh_provider=self.refresh_record + ) + + def _record_from_token_response( + self, token_response: _TokenResponse + ) -> OAuthProviderRecord: + if not token_response.access_token: + raise RuntimeError("Codex token response did not include access_token") + account = OAuthAccount() + if token_response.id_token: + account = _account_from_id_token(token_response.id_token) + return OAuthProviderRecord( + issuer=self.profile.issuer, + client_id=self.profile.client_id, + token_endpoint=self.profile.token_endpoint, + revoke_endpoint=self.profile.revoke_endpoint, + base_url=self.profile.base_url, + scopes=list(self.profile.scopes), + tokens=OAuthTokens( + id_token=token_response.id_token, + access_token=token_response.access_token, + refresh_token=token_response.refresh_token, + ), + account=account, + last_refresh_at=datetime.now(UTC), + ) + + +def _validate_same_account(old: OAuthAccount, new: OAuthAccount) -> None: + if ( + old.chatgpt_account_id + and new.chatgpt_account_id + and old.chatgpt_account_id != new.chatgpt_account_id + ): + raise RuntimeError( + "Codex refresh returned a different ChatGPT account; " + "run `coding-agent oauth login codex` again." + ) + if ( + old.chatgpt_user_id + and new.chatgpt_user_id + and old.chatgpt_user_id != new.chatgpt_user_id + ): + raise RuntimeError( + "Codex refresh returned a different ChatGPT user; " + "run `coding-agent oauth login codex` again." + ) + + +def create_codex_token_source( + *, store: OAuthStore | None = None +) -> StoreBackedTokenSource: + """Create a token source for Codex OAuth.""" + return CodexOAuthClient(store=store).make_token_source() + + +def redact_record(record: OAuthProviderRecord) -> dict[str, Any]: + """Return a redacted copy of a provider record (no token values).""" + data = record.model_dump(mode="json") + tokens = data.get("tokens") + if isinstance(tokens, dict): + for key in list(tokens): + if tokens[key]: + tokens[key] = "" + return data diff --git a/coding-agent/src/coding_agent/oauth/store.py b/coding-agent/src/coding_agent/oauth/store.py new file mode 100644 index 000000000..6f0007cf6 --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/store.py @@ -0,0 +1,228 @@ +"""File-backed OAuth credential store with process-level locking.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import stat +import tempfile +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeVar, cast + +if TYPE_CHECKING: + from types import TracebackType + +from coding_agent.oauth.types import AuthFile, OAuthProviderRecord, TokenSnapshot + +T = TypeVar("T") + +DEFAULT_AUTH_DIR = Path.home() / ".coding-agent" / "oauth" +DEFAULT_AUTH_PATH = DEFAULT_AUTH_DIR / "auth.json" + + +class FileLock: + """Cross-platform advisory file lock.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._file: Any = None + self._msvcrt: Any = None + + def __enter__(self) -> None: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + self._file = self.path.open("a+") + if os.name == "nt": + import msvcrt + + self._msvcrt = msvcrt + if self._file.tell() == 0: + self._file.write("\0") + self._file.flush() + self._file.seek(0) + self._msvcrt.locking(self._file.fileno(), self._msvcrt.LK_LOCK, 1) + else: + _lock_posix_file(self._file.fileno()) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self._file is None: + return + try: + if os.name == "nt" and self._msvcrt is not None: + self._file.seek(0) + self._msvcrt.locking(self._file.fileno(), self._msvcrt.LK_UNLCK, 1) + else: + _unlock_posix_file(self._file.fileno()) + finally: + self._file.close() + self._file = None + + +def _lock_posix_file(fd: int) -> None: + import fcntl + + fcntl_module = cast(Any, fcntl) + fcntl_module.flock(fd, fcntl_module.LOCK_EX) + + +def _unlock_posix_file(fd: int) -> None: + import fcntl + + fcntl_module = cast(Any, fcntl) + fcntl_module.flock(fd, fcntl_module.LOCK_UN) + + +class OAuthStore: + """File-backed OAuth credential store with process-level locking.""" + + def __init__(self, path: Path | str | None = None) -> None: + self.path = Path(path).expanduser() if path is not None else DEFAULT_AUTH_PATH + self.lock_path = self.path.with_suffix(self.path.suffix + ".lock") + + def load(self) -> AuthFile: + self._ensure_parent() + with self._locked(): + return self._load_unlocked() + + def save(self, auth_file: AuthFile) -> None: + self._ensure_parent() + with self._locked(): + self._save_unlocked(auth_file) + + def get_provider(self, provider_name: str) -> OAuthProviderRecord | None: + return self.load().providers.get(provider_name) + + def set_provider(self, provider_name: str, record: OAuthProviderRecord) -> None: + def update(auth_file: AuthFile) -> None: + auth_file.providers[provider_name] = record + + self.update(update) + + def delete_provider(self, provider_name: str) -> OAuthProviderRecord | None: + deleted: OAuthProviderRecord | None = None + + def update(auth_file: AuthFile) -> None: + nonlocal deleted + deleted = auth_file.providers.pop(provider_name, None) + + self.update(update) + return deleted + + def update(self, updater: Callable[[AuthFile], T]) -> T: + self._ensure_parent() + with self._locked(): + auth_file = self._load_unlocked() + result = updater(auth_file) + self._save_unlocked(auth_file) + return result + + def _ensure_parent(self) -> None: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with contextlib.suppress(PermissionError): + os.chmod(self.path.parent, 0o700) + + def _locked(self) -> FileLock: + return FileLock(self.lock_path) + + def _load_unlocked(self) -> AuthFile: + if not self.path.exists(): + return AuthFile() + self._repair_file_mode_unlocked() + with self.path.open("r", encoding="utf-8") as file: + data = json.load(file) + return AuthFile.model_validate(data) + + def _save_unlocked(self, auth_file: AuthFile) -> None: + payload = auth_file.model_dump(mode="json", exclude_none=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", + suffix=".tmp", + dir=self.path.parent, + text=True, + ) + tmp_path = Path(tmp_name) + try: + os.chmod(tmp_path, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(payload, file, indent=2, sort_keys=True) + file.write("\n") + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, self.path) + except BaseException: + with contextlib.suppress(FileNotFoundError): + tmp_path.unlink() + raise + with contextlib.suppress(PermissionError): + os.chmod(self.path, 0o600) + + def _repair_file_mode_unlocked(self) -> None: + mode = stat.S_IMODE(self.path.stat().st_mode) + if mode != 0o600: + os.chmod(self.path, 0o600) + + +class StoreBackedTokenSource: + """OAuthTokenSource backed by OAuthStore and a provider-specific refresher.""" + + def __init__( + self, + provider_name: str, + *, + store: OAuthStore | None = None, + refresh_provider: Callable[[OAuthProviderRecord], OAuthProviderRecord], + ) -> None: + self.provider_name = provider_name + self.store = store or OAuthStore() + self._refresh_provider = refresh_provider + + async def get_token(self) -> TokenSnapshot: + record = self.store.get_provider(self.provider_name) + if record is None: + raise RuntimeError( + f"OAuth provider is not logged in: {self.provider_name}. " + f"Run `coding-agent oauth login {self.provider_name}` first." + ) + return _snapshot(self.provider_name, record) + + async def refresh_token(self) -> TokenSnapshot: + refreshed = await asyncio.to_thread(self._refresh_and_store) + return _snapshot(self.provider_name, refreshed) + + def _refresh_and_store(self) -> OAuthProviderRecord: + record = self.store.get_provider(self.provider_name) + if record is None: + raise RuntimeError(f"OAuth provider is not logged in: {self.provider_name}") + + refreshed = self._refresh_provider(record) + + def persist(auth_file: AuthFile) -> None: + current = auth_file.providers.get(self.provider_name) + if current is None: + raise RuntimeError( + f"OAuth provider is not logged in: {self.provider_name}" + ) + if current != record: + raise RuntimeError( + f"OAuth provider changed during refresh: {self.provider_name}" + ) + auth_file.providers[self.provider_name] = refreshed + + self.store.update(persist) + return refreshed + + +def _snapshot(provider_name: str, record: OAuthProviderRecord) -> TokenSnapshot: + return TokenSnapshot( + provider_name=provider_name, + access_token=record.tokens.access_token, + account=record.account, + base_url=record.base_url, + ) diff --git a/coding-agent/src/coding_agent/oauth/types.py b/coding-agent/src/coding_agent/oauth/types.py new file mode 100644 index 000000000..181b0c35a --- /dev/null +++ b/coding-agent/src/coding_agent/oauth/types.py @@ -0,0 +1,95 @@ +"""OAuth data models.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Protocol + +from pydantic import BaseModel, Field + + +class OAuthTokens(BaseModel): + """OAuth token material stored for a provider.""" + + id_token: str | None = None + access_token: str + refresh_token: str | None = None + + +class OAuthAccount(BaseModel): + """Account metadata derived from provider tokens.""" + + email: str | None = None + chatgpt_user_id: str | None = None + chatgpt_account_id: str | None = None + chatgpt_plan_type: str | None = None + chatgpt_account_is_fedramp: bool = False + + +class OAuthProviderRecord(BaseModel): + """Stored OAuth configuration and credential record for one provider.""" + + type: str = "oauth2" + issuer: str + client_id: str + token_endpoint: str + revoke_endpoint: str | None = None + base_url: str | None = None + scopes: list[str] = Field(default_factory=list) + tokens: OAuthTokens + account: OAuthAccount = Field(default_factory=OAuthAccount) + last_refresh_at: datetime | None = None + + def with_refreshed_tokens( + self, + *, + id_token: str | None = None, + access_token: str | None = None, + refresh_token: str | None = None, + account: OAuthAccount | None = None, + ) -> OAuthProviderRecord: + """Return an updated copy, preserving fields the refresh response omits.""" + return self.model_copy( + update={ + "tokens": self.tokens.model_copy( + update={ + "id_token": id_token + if id_token is not None + else self.tokens.id_token, + "access_token": access_token + if access_token is not None + else self.tokens.access_token, + "refresh_token": refresh_token + if refresh_token is not None + else self.tokens.refresh_token, + } + ), + "account": account if account is not None else self.account, + "last_refresh_at": datetime.now(UTC), + } + ) + + +class AuthFile(BaseModel): + """On-disk auth file schema for ~/.coding-agent/oauth/auth.json.""" + + version: int = 1 + providers: dict[str, OAuthProviderRecord] = Field(default_factory=dict) + + +class TokenSnapshot(BaseModel): + """Provider token state safe for request construction.""" + + provider_name: str + access_token: str + account: OAuthAccount = Field(default_factory=OAuthAccount) + base_url: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class OAuthTokenSource(Protocol): + """Async token source consumed by OAuth-backed model providers.""" + + async def get_token(self) -> TokenSnapshot: ... + + async def refresh_token(self) -> TokenSnapshot: ... diff --git a/coding-agent/src/coding_agent/plugins/llm_provider.py b/coding-agent/src/coding_agent/plugins/llm_provider.py index f1d10b997..b4f7106a5 100644 --- a/coding-agent/src/coding_agent/plugins/llm_provider.py +++ b/coding-agent/src/coding_agent/plugins/llm_provider.py @@ -117,12 +117,73 @@ def provide_llm(self, **kwargs: Any) -> LLMProvider: api_key=api_key, base_url=self._base_url or "https://api.stepfun.com/v1", ) + elif self._provider_name == "codex": + from coding_agent.oauth.auth import OAuthBearerAuth + from coding_agent.oauth.codex import CODEX_BASE_URL + from coding_agent.oauth.store import OAuthStore + from coding_agent.providers.openai_compat import OpenAICompatProvider + + store = OAuthStore() + record = store.get_provider("codex") + if record is None: + raise RuntimeError( + "Codex OAuth provider is not logged in. " + "Run `coding-agent oauth login codex` first." + ) + + token_source = StoreBackedCodexTokenSource(store) + auth = OAuthBearerAuth( + token_source, + provider_name="codex", + ) + base_url = self._base_url or CODEX_BASE_URL + + self._instance = OpenAICompatProvider( + model=self._model, + api_key="oauth-managed", + base_url=base_url, + httpx_auth=auth, + ) else: raise ValueError(f"unsupported provider: {self._provider_name}") return self._instance +class StoreBackedCodexTokenSource: + """Small adapter that avoids constructing network clients until refresh.""" + + def __init__(self, store: Any) -> None: + self._store = store + + async def get_token(self) -> Any: + from coding_agent.oauth.store import StoreBackedTokenSource + + return await StoreBackedTokenSource( + "codex", + store=self._store, + refresh_provider=self._refresh_record, + ).get_token() + + async def refresh_token(self) -> Any: + from coding_agent.oauth.store import StoreBackedTokenSource + + return await StoreBackedTokenSource( + "codex", + store=self._store, + refresh_provider=self._refresh_record, + ).refresh_token() + + def _refresh_record(self, record: Any) -> Any: + from coding_agent.oauth.codex import CodexOAuthClient + + client = CodexOAuthClient(store=self._store) + try: + return client.refresh_record(record) + finally: + client.close() + + async def adapt_stream_events( old_stream: AsyncIterator[OldStreamEvent], ) -> AsyncIterator[NewStreamEvent]: diff --git a/coding-agent/src/coding_agent/providers/openai_compat.py b/coding-agent/src/coding_agent/providers/openai_compat.py index b6c32a565..5ce3e9406 100644 --- a/coding-agent/src/coding_agent/providers/openai_compat.py +++ b/coding-agent/src/coding_agent/providers/openai_compat.py @@ -9,7 +9,7 @@ from typing import Any, AsyncIterator import httpx -from openai import AsyncOpenAI, APIError, RateLimitError, APIStatusError +from openai import AsyncOpenAI from agentkit.providers.models import ( StreamEvent, @@ -57,6 +57,7 @@ def __init__( retry_base_delay: float = 1.0, retry_max_delay: float = 60.0, default_headers: dict[str, str] | None = None, + httpx_auth: httpx.Auth | None = None, ): self._model = model # Handle SecretStr by extracting actual value @@ -72,6 +73,7 @@ def __init__( self._http_client = httpx.AsyncClient( limits=limits, timeout=timeout_config, + auth=httpx_auth, ) self._client = AsyncOpenAI( diff --git a/coding-agent/tests/cli/test_oauth_commands.py b/coding-agent/tests/cli/test_oauth_commands.py new file mode 100644 index 000000000..932b0d082 --- /dev/null +++ b/coding-agent/tests/cli/test_oauth_commands.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from coding_agent.cli.main import main +from coding_agent.oauth.store import OAuthStore +from coding_agent.oauth.types import OAuthProviderRecord, OAuthTokens + + +def _store_record(auth_file: Path) -> None: + store = OAuthStore(auth_file) + store.set_provider( + "codex", + OAuthProviderRecord( + issuer="https://auth.openai.com", + client_id="codex-client", + token_endpoint="https://auth.openai.com/oauth/token", + base_url="https://chatgpt.com/backend-api/codex", + tokens=OAuthTokens( + access_token="secret-access-token", + refresh_token="secret-refresh-token", + ), + ), + ) + + +def test_oauth_status_reports_missing_provider_without_creating_secrets( + tmp_path: Path, +) -> None: + runner = CliRunner() + auth_file = tmp_path / "auth.json" + + result = runner.invoke( + main, + ["oauth", "status", "codex", "--auth-file", str(auth_file)], + ) + + assert result.exit_code == 0 + assert "codex: not logged in" in result.output + assert "secret" not in result.output + + +def test_oauth_doctor_redacts_tokens(tmp_path: Path) -> None: + runner = CliRunner() + auth_file = tmp_path / "auth.json" + _store_record(auth_file) + + result = runner.invoke( + main, + ["oauth", "doctor", "--auth-file", str(auth_file)], + ) + + assert result.exit_code == 0 + assert "Providers: codex" in result.output + assert "" in result.output + assert "secret-access-token" not in result.output + assert "secret-refresh-token" not in result.output + + +def test_oauth_doctor_handles_missing_auth_directory(tmp_path: Path) -> None: + runner = CliRunner() + auth_file = tmp_path / "missing" / "auth.json" + + result = runner.invoke( + main, + ["oauth", "doctor", "--auth-file", str(auth_file)], + ) + + assert result.exit_code == 0 + assert "Providers: none" in result.output + assert "Directory mode: 700 (ok)" in result.output + assert "File mode:" not in result.output diff --git a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py index b71b4f63b..651e5165d 100644 --- a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py +++ b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py @@ -2,6 +2,8 @@ from agentkit.providers.protocol import LLMProvider from coding_agent.plugins.llm_provider import LLMProviderPlugin +from coding_agent.oauth.store import OAuthStore +from coding_agent.oauth.types import OAuthProviderRecord, OAuthTokens from coding_agent.providers.anthropic import AnthropicProvider from coding_agent.providers.copilot import CopilotProvider from coding_agent.providers.openai_compat import OpenAICompatProvider @@ -50,6 +52,40 @@ def test_unknown_provider_raises(self): with pytest.raises(ValueError, match="unsupported provider"): plugin.provide_llm() + def test_codex_provider_requires_login(self, monkeypatch, tmp_path): + import coding_agent.oauth.store as store_module + + monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", tmp_path / "auth.json") + plugin = LLMProviderPlugin(provider="codex", model="codex-mini", api_key="") + + with pytest.raises(RuntimeError, match="oauth login codex"): + plugin.provide_llm() + + def test_codex_provider_uses_oauth_backed_openai_compat( + self, monkeypatch, tmp_path + ): + import coding_agent.oauth.store as store_module + + auth_path = tmp_path / "auth.json" + monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", auth_path) + OAuthStore(auth_path).set_provider( + "codex", + OAuthProviderRecord( + issuer="https://auth.openai.com", + client_id="codex-client", + token_endpoint="https://auth.openai.com/oauth/token", + base_url="https://chatgpt.com/backend-api/codex", + tokens=OAuthTokens(access_token="access-token"), + ), + ) + plugin = LLMProviderPlugin(provider="codex", model="codex-mini", api_key="") + + result = plugin.provide_llm() + + assert isinstance(result, OpenAICompatProvider) + assert result.model_name == "codex-mini" + assert "chatgpt.com/backend-api/codex" in str(result._client.base_url) + class TestKimiProvider: def test_kimi_creates_openai_compat_instance(self): diff --git a/coding-agent/tests/coding_agent/test_codex_oauth.py b/coding-agent/tests/coding_agent/test_codex_oauth.py new file mode 100644 index 000000000..69f74beb7 --- /dev/null +++ b/coding-agent/tests/coding_agent/test_codex_oauth.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import os +import stat +from dataclasses import dataclass +from pathlib import Path + +import httpx +import pytest + +from coding_agent.oauth.auth import OAuthBearerAuth +from coding_agent.oauth.codex import CODEX_BASE_URL, redact_record +from coding_agent.oauth.store import OAuthStore, StoreBackedTokenSource +from coding_agent.oauth.types import OAuthProviderRecord, OAuthTokens + + +def _record( + access_token: str = "access-token", + refresh_token: str | None = "refresh-token", +) -> OAuthProviderRecord: + return OAuthProviderRecord( + issuer="https://auth.openai.com", + client_id="codex-client", + token_endpoint="https://auth.openai.com/oauth/token", + revoke_endpoint="https://auth.openai.com/oauth/revoke", + base_url=CODEX_BASE_URL, + scopes=["openid", "offline_access"], + tokens=OAuthTokens( + access_token=access_token, + refresh_token=refresh_token, + ), + ) + + +def test_oauth_store_writes_private_file_and_round_trips_record( + tmp_path: Path, +) -> None: + auth_path = tmp_path / "nested" / "auth.json" + store = OAuthStore(auth_path) + + store.set_provider("codex", _record()) + + if os.name != "nt": + assert stat.S_IMODE(auth_path.parent.stat().st_mode) == 0o700 + assert stat.S_IMODE(auth_path.stat().st_mode) == 0o600 + loaded = store.get_provider("codex") + assert loaded is not None + assert loaded.tokens.access_token == "access-token" + assert loaded.tokens.refresh_token == "refresh-token" + + +@pytest.mark.asyncio +async def test_store_backed_token_source_refreshes_and_persists_token( + tmp_path: Path, +) -> None: + store = OAuthStore(tmp_path / "auth.json") + store.set_provider("codex", _record(access_token="old-token")) + + def refresh_provider(record: OAuthProviderRecord) -> OAuthProviderRecord: + assert record.tokens.access_token == "old-token" + return record.with_refreshed_tokens(access_token="new-token") + + source = StoreBackedTokenSource( + "codex", + store=store, + refresh_provider=refresh_provider, + ) + + snapshot = await source.refresh_token() + + assert snapshot.access_token == "new-token" + assert store.get_provider("codex").tokens.access_token == "new-token" # type: ignore[union-attr] + + +@pytest.mark.asyncio +async def test_store_backed_token_source_refreshes_outside_store_update( + tmp_path: Path, +) -> None: + store = OAuthStore(tmp_path / "auth.json") + store.set_provider("codex", _record(access_token="old-token")) + update_depth = 0 + original_update = store.update + + def tracking_update(updater): + nonlocal update_depth + update_depth += 1 + try: + return original_update(updater) + finally: + update_depth -= 1 + + store.update = tracking_update # type: ignore[method-assign] + + def refresh_provider(record: OAuthProviderRecord) -> OAuthProviderRecord: + assert update_depth == 0 + return record.with_refreshed_tokens(access_token="new-token") + + source = StoreBackedTokenSource( + "codex", + store=store, + refresh_provider=refresh_provider, + ) + + snapshot = await source.refresh_token() + + assert snapshot.access_token == "new-token" + + +@pytest.mark.asyncio +async def test_store_backed_token_source_missing_login_fails_fast( + tmp_path: Path, +) -> None: + source = StoreBackedTokenSource( + "codex", + store=OAuthStore(tmp_path / "auth.json"), + refresh_provider=lambda record: record, + ) + + with pytest.raises(RuntimeError, match="not logged in"): + await source.get_token() + + +@dataclass +class _MemoryTokenSource: + access_token: str = "old-token" + refresh_count: int = 0 + + async def get_token(self): + from coding_agent.oauth.types import TokenSnapshot + + return TokenSnapshot(provider_name="codex", access_token=self.access_token) + + async def refresh_token(self): + from coding_agent.oauth.types import TokenSnapshot + + self.refresh_count += 1 + self.access_token = "new-token" + return TokenSnapshot(provider_name="codex", access_token=self.access_token) + + +@pytest.mark.asyncio +async def test_oauth_bearer_auth_refreshes_on_unauthorized_response() -> None: + seen_authorization: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_authorization.append(request.headers["Authorization"]) + if len(seen_authorization) == 1: + return httpx.Response(401, request=request) + return httpx.Response(200, json={"ok": True}, request=request) + + token_source = _MemoryTokenSource() + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + auth=OAuthBearerAuth(token_source), + ) as client: + response = await client.get("https://example.invalid/test") + + assert response.status_code == 200 + assert seen_authorization == ["Bearer old-token", "Bearer new-token"] + assert token_source.refresh_count == 1 + + +def test_redact_record_removes_token_values() -> None: + safe = redact_record(_record(access_token="secret-access")) + + assert safe["tokens"]["access_token"] == "" + assert safe["tokens"]["refresh_token"] == "" + assert "secret-access" not in str(safe)