Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/OAUTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# OpenAI OAuth 2.0 Guide

## Why OAuth?

- Browser-based OpenAI account authentication
- Automatic token refresh
- Codex CLI credential import
- Account status inspection in Pantheon

## Important Limitation

Pantheon's OAuth support manages OpenAI account credentials only. It does not treat the
resulting OAuth access token as a substitute for `OPENAI_API_KEY` when calling the OpenAI API.

The current exception is Pantheon's dedicated Codex transport: models whose name contains
`codex` can be routed through the ChatGPT/Codex backend using OAuth credentials when available.

You can trigger this path explicitly with a Codex-prefixed model name such as:

```bash
/model codex/gpt-5.4
```

To call OpenAI models through the standard OpenAI API path, you still need one of:

- `OPENAI_API_KEY`
- `LLM_API_KEY` with a compatible base URL
- `CUSTOM_OPENAI_API_BASE` plus `CUSTOM_OPENAI_API_KEY`

## Integration Risk

Pantheon's OpenAI OAuth integration reuses the Codex CLI OAuth client identity.
This is not a public third-party OAuth app registration flow.

Implications:

- OpenAI can revoke, restrict, or change this integration path at any time
- A working setup today may break without a Pantheon code change
- This path should be treated as best-effort, not as a long-term stable contract

For maintainers:

- Do not assume the current client ID / originator values are durable
- Prefer isolating Codex-specific OAuth behavior from standard OpenAI API auth
- Be prepared to disable or replace this path if OpenAI changes upstream behavior

## Quick Start

```bash
pantheon
/oauth login
# Browser opens - log in and authorize
```

## REPL Commands

| Command | Description |
|---------|-------------|
| `/oauth status` | Check authentication |
| `/oauth login` | Initiate login |
| `/oauth logout` | Clear credentials |

## API Reference

### `get_oauth_manager() -> OAuthManager`

Get the singleton provider registry for OAuth-capable providers.

### `OpenAIOAuthProvider`

| Method | Returns | Description |
|--------|---------|-------------|
| `login()` | `bool` | Start OAuth flow |
| `ensure_access_token()` | `str\|None` | Get a valid access token |
| `ensure_access_token_with_codex_fallback()` | `str\|None` | Get token, importing Codex CLI auth if needed |
| `build_codex_auth_context()` | `dict\|None` | Build ChatGPT/Codex backend auth context |
| `get_status()` | `OAuthStatus` | Current auth status |
| `logout()` | `None` | Revoke tokens and remove local auth file |

### Example

```python
from pantheon.auth.oauth_manager import get_oauth_manager

mgr = get_oauth_manager()
provider = mgr.get_provider("openai")
token = provider.ensure_access_token()
if token:
status = provider.get_status()
print(f"Logged in as: {status.email}")
```

## Configuration

```python
# Custom token location
from pathlib import Path
from pantheon.auth.openai_provider import OpenAIOAuthProvider

provider = OpenAIOAuthProvider(auth_path=Path("/custom/path.json"))
```

```bash
# OpenAI API model calls still require an API key
export OPENAI_API_KEY="sk-..."
```

## Troubleshooting

| Error | Solution |
|-------|----------|
| `No module named 'requests'` | `pip install requests` |
| `No module named 'jwt'` | `pip install pyjwt` |
| `No module named 'cryptography'` | `pip install cryptography` |
| Browser didn't open | Set default browser in OS settings |
| Token expired | Run `/oauth login` to re-authenticate |
| Can't import Codex | Use browser login instead |

## Security

- Tokens stored at `~/.pantheon/oauth_openai.json`
- Tokens auto-refresh when ~5 min from expiry
- JWT claims used for email / org / project context are signature-verified before use
- OAuth callback requests are checked against `Origin` / `Referer` when headers are present
- Use `/oauth logout` on shared systems

## See Also

- [OpenAI OAuth Docs](https://platform.openai.com/docs/guides/oauth)
- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
6 changes: 6 additions & 0 deletions pantheon/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Pantheon authentication modules.

This package provides authentication support for various LLM providers,
including OAuth 2.0 integration with OpenAI Codex.
"""
239 changes: 239 additions & 0 deletions pantheon/auth/oauth_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""
OAuth Types and Protocols for PantheonOS.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol, Optional

@dataclass
class OAuthTokens:
"""Generic OAuth tokens."""
id_token: str
access_token: str
refresh_token: str
account_id: Optional[str] = None
organization_id: Optional[str] = None
project_id: Optional[str] = None

@classmethod
def from_dict(cls, data: dict) -> OAuthTokens:
return cls(
id_token=data["id_token"],
access_token=data["access_token"],
refresh_token=data["refresh_token"],
account_id=data.get("account_id"),
organization_id=data.get("organization_id"),
project_id=data.get("project_id"),
)

def to_dict(self) -> dict:
return {
"id_token": self.id_token,
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"account_id": self.account_id,
"organization_id": self.organization_id,
"project_id": self.project_id,
}


@dataclass
class OAuthStatus:
"""Generic OAuth status."""
authenticated: bool
email: str = ""
organization_id: Optional[str] = None
project_id: Optional[str] = None
token_expires_at: Optional[float] = None
provider: str = ""


@dataclass
class AuthRecord:
"""Generic OAuth auth record."""
provider: str
tokens: OAuthTokens
last_refresh: str
email: Optional[str] = None

@classmethod
def from_dict(cls, data: dict) -> AuthRecord:
return cls(
provider=data.get("provider", "unknown"),
tokens=OAuthTokens.from_dict(data["tokens"]),
last_refresh=data.get("last_refresh", ""),
email=data.get("email"),
)

def to_dict(self) -> dict:
result = {
"provider": self.provider,
"tokens": self.tokens.to_dict(),
"last_refresh": self.last_refresh,
}
if self.email:
result["email"] = self.email
return result


class OAuthProvider(Protocol):
"""Protocol for OAuth providers."""

@property
def name(self) -> str:
"""Provider name."""
...

@property
def display_name(self) -> str:
"""Display name for UI."""
...

def login(
self,
*,
open_browser: bool = True,
timeout_seconds: int = 300,
) -> bool:
"""Initiate OAuth login flow."""
...

def get_status(self) -> OAuthStatus:
"""Get current OAuth status."""
...

def logout(self) -> None:
"""Clear OAuth credentials."""
...

def ensure_access_token(self, refresh_if_needed: bool = True) -> Optional[str]:
"""Get a valid access token."""
...


class OAuthManager:
"""Manages multiple OAuth providers."""

def __init__(self):
self._providers: dict[str, OAuthProvider] = {}
self._default_provider: str = "openai"

def register(self, provider: OAuthProvider) -> None:
"""Register an OAuth provider."""
self._providers[provider.name] = provider

def set_default(self, provider_name: str) -> None:
"""Set the default provider."""
if provider_name not in self._providers:
raise ValueError(f"Unknown provider: {provider_name}")
self._default_provider = provider_name

@property
def default_provider(self) -> str:
"""Get the default provider name."""
return self._default_provider

def list_providers(self) -> list[str]:
"""List all registered provider names."""
return list(self._providers.keys())

def get_provider(self, name: Optional[str] = None) -> OAuthProvider:
"""Get a provider by name, or the default provider."""
provider_name = name or self._default_provider
if provider_name not in self._providers:
raise ValueError(f"Unknown provider: {provider_name}")
return self._providers[provider_name]

def login(
self,
provider: Optional[str] = None,
*,
open_browser: bool = True,
timeout_seconds: int = 300,
) -> bool:
"""Login with a specific provider."""
p = self.get_provider(provider)
return p.login(open_browser=open_browser, timeout_seconds=timeout_seconds)

def get_status(self, provider: Optional[str] = None) -> OAuthStatus:
"""Get status from a specific provider."""
p = self.get_provider(provider)
status = p.get_status()
status.provider = p.name
return status

def logout(self, provider: Optional[str] = None) -> None:
"""Logout from a specific provider."""
p = self.get_provider(provider)
p.logout()

def ensure_access_token(
self,
provider: Optional[str] = None,
refresh_if_needed: bool = True,
) -> Optional[str]:
"""Get a valid access token from a specific provider."""
p = self.get_provider(provider)
return p.ensure_access_token(refresh_if_needed=refresh_if_needed)


_oauth_manager: Optional[OAuthManager] = None


def get_oauth_manager() -> OAuthManager:
"""Get the OAuth manager singleton."""
global _oauth_manager
if _oauth_manager is None:
_oauth_manager = OAuthManager()
from pantheon.auth.openai_provider import OpenAIOAuthProvider
_oauth_manager.register(OpenAIOAuthProvider())
return _oauth_manager


def reset_oauth_manager() -> None:
"""Reset the OAuth manager singleton."""
global _oauth_manager
_oauth_manager = None


def get_oauth_token(provider: str = "openai", refresh_if_needed: bool = True) -> Optional[str]:
"""Get a valid OAuth access token for the specified provider.

This is a convenience function for other modules to get OAuth tokens.

Args:
provider: The OAuth provider name (default: "openai")
refresh_if_needed: Whether to refresh the token if expired

Returns:
The access token string, or None if not available
"""
try:
manager = get_oauth_manager()
return manager.ensure_access_token(provider, refresh_if_needed)
except Exception:
return None


def is_oauth_available(provider: str = "openai") -> bool:
"""Check if OAuth is available for the specified provider.

Args:
provider: The OAuth provider name (default: "openai")

Returns:
True if OAuth tokens are available, False otherwise
"""
try:
from pathlib import Path

manager = get_oauth_manager()
oauth_provider = manager.get_provider(provider)

# Check if auth file exists
if hasattr(oauth_provider, 'auth_path') and oauth_provider.auth_path.exists():
return True
return False
except Exception:
return False
Loading