codex-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_auth-0.1.0/.gitignore +23 -0
- codex_auth-0.1.0/LICENSE +11 -0
- codex_auth-0.1.0/PKG-INFO +128 -0
- codex_auth-0.1.0/README.md +106 -0
- codex_auth-0.1.0/pyproject.toml +34 -0
- codex_auth-0.1.0/src/codex_auth/__init__.py +46 -0
- codex_auth-0.1.0/src/codex_auth/auth.py +268 -0
- codex_auth-0.1.0/src/codex_auth/client.py +38 -0
- codex_auth-0.1.0/src/codex_auth/patch.py +280 -0
- codex_auth-0.1.0/src/codex_auth/py.typed +0 -0
- codex_auth-0.1.0/src/codex_auth/tokens.py +138 -0
- codex_auth-0.1.0/test_live.py +26 -0
- codex_auth-0.1.0/tests/__init__.py +0 -0
- codex_auth-0.1.0/tests/test_auth.py +62 -0
- codex_auth-0.1.0/tests/test_patch.py +192 -0
- codex_auth-0.1.0/tests/test_tokens.py +117 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.egg-info/
|
|
5
|
+
*.egg
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
*.so
|
|
10
|
+
.tox/
|
|
11
|
+
.nox/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
htmlcov/
|
|
16
|
+
.coverage
|
|
17
|
+
.coverage.*
|
|
18
|
+
*.cover
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
env/
|
|
22
|
+
.env
|
|
23
|
+
*.log
|
codex_auth-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
2
|
+
Version 2, December 2004
|
|
3
|
+
|
|
4
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
|
5
|
+
copies of this license document, and changing it is allowed as long
|
|
6
|
+
as the name is changed.
|
|
7
|
+
|
|
8
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
9
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
10
|
+
|
|
11
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in OpenAI SDK patch for ChatGPT Codex OAuth authentication
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: httpx>=0.24
|
|
17
|
+
Requires-Dist: openai>=1.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# codex-auth
|
|
24
|
+
|
|
25
|
+
Drop-in OAuth for the OpenAI Python SDK — use the ChatGPT Codex API with your Pro/Plus account instead of an API key.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install codex-auth
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import codex_auth
|
|
37
|
+
|
|
38
|
+
from openai import OpenAI
|
|
39
|
+
client = OpenAI() # no API key needed
|
|
40
|
+
|
|
41
|
+
response = client.responses.create(
|
|
42
|
+
model="gpt-5.1-codex-mini",
|
|
43
|
+
input="Write a one-sentence bedtime story about a unicorn.",
|
|
44
|
+
)
|
|
45
|
+
print(response.output_text)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A browser window opens on first run for OAuth. Tokens are cached in
|
|
49
|
+
`~/.codex-auth/auth.json` and refreshed automatically.
|
|
50
|
+
|
|
51
|
+
Both streaming and non-streaming calls work — the library handles the
|
|
52
|
+
Codex endpoint's streaming requirement transparently.
|
|
53
|
+
|
|
54
|
+
### Streaming
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
stream = client.responses.create(
|
|
58
|
+
model="gpt-5.1-codex-mini",
|
|
59
|
+
input="Write a hello-world in Rust.",
|
|
60
|
+
stream=True,
|
|
61
|
+
)
|
|
62
|
+
for event in stream:
|
|
63
|
+
if event.type == "response.completed":
|
|
64
|
+
print(event.response.output_text)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `chat.completions` compatibility
|
|
68
|
+
|
|
69
|
+
Existing code using `chat.completions` works too — requests are converted
|
|
70
|
+
to the Responses API format automatically:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
response = client.chat.completions.create(
|
|
74
|
+
model="gpt-5.1-codex-mini",
|
|
75
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
76
|
+
)
|
|
77
|
+
print(response.choices[0].message.content)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Explicit client
|
|
81
|
+
|
|
82
|
+
If you prefer not to monkey-patch:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from codex_auth import CodexClient
|
|
86
|
+
|
|
87
|
+
client = CodexClient() # browser / device auth
|
|
88
|
+
client = CodexClient(token="…") # existing token
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Async variant:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from codex_auth import AsyncCodexClient
|
|
95
|
+
client = AsyncCodexClient()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Disable auto-patch
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
export CODEX_AUTH_NO_PATCH=1
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Or in code:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import codex_auth
|
|
108
|
+
codex_auth.init(auto_patch=False)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## How it works
|
|
112
|
+
|
|
113
|
+
A custom [httpx transport](https://www.python-httpx.org/advanced/transports/) intercepts OpenAI SDK requests to:
|
|
114
|
+
|
|
115
|
+
1. Rewrite URLs to the Codex backend (`chatgpt.com/backend-api/codex/responses`)
|
|
116
|
+
2. Convert `chat.completions` payloads to the Responses API format
|
|
117
|
+
3. Buffer SSE responses for non-streaming callers
|
|
118
|
+
4. Inject OAuth bearer tokens and refresh them transparently
|
|
119
|
+
|
|
120
|
+
Browser-based PKCE auth is used on desktop; device-code flow on headless/SSH.
|
|
121
|
+
|
|
122
|
+
## Token storage
|
|
123
|
+
|
|
124
|
+
`~/.codex-auth/auth.json` (mode `0600`). Override with `CODEX_AUTH_TOKEN` env var.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# codex-auth
|
|
2
|
+
|
|
3
|
+
Drop-in OAuth for the OpenAI Python SDK — use the ChatGPT Codex API with your Pro/Plus account instead of an API key.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install codex-auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import codex_auth
|
|
15
|
+
|
|
16
|
+
from openai import OpenAI
|
|
17
|
+
client = OpenAI() # no API key needed
|
|
18
|
+
|
|
19
|
+
response = client.responses.create(
|
|
20
|
+
model="gpt-5.1-codex-mini",
|
|
21
|
+
input="Write a one-sentence bedtime story about a unicorn.",
|
|
22
|
+
)
|
|
23
|
+
print(response.output_text)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A browser window opens on first run for OAuth. Tokens are cached in
|
|
27
|
+
`~/.codex-auth/auth.json` and refreshed automatically.
|
|
28
|
+
|
|
29
|
+
Both streaming and non-streaming calls work — the library handles the
|
|
30
|
+
Codex endpoint's streaming requirement transparently.
|
|
31
|
+
|
|
32
|
+
### Streaming
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
stream = client.responses.create(
|
|
36
|
+
model="gpt-5.1-codex-mini",
|
|
37
|
+
input="Write a hello-world in Rust.",
|
|
38
|
+
stream=True,
|
|
39
|
+
)
|
|
40
|
+
for event in stream:
|
|
41
|
+
if event.type == "response.completed":
|
|
42
|
+
print(event.response.output_text)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `chat.completions` compatibility
|
|
46
|
+
|
|
47
|
+
Existing code using `chat.completions` works too — requests are converted
|
|
48
|
+
to the Responses API format automatically:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
response = client.chat.completions.create(
|
|
52
|
+
model="gpt-5.1-codex-mini",
|
|
53
|
+
messages=[{"role": "user", "content": "Hello!"}],
|
|
54
|
+
)
|
|
55
|
+
print(response.choices[0].message.content)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Explicit client
|
|
59
|
+
|
|
60
|
+
If you prefer not to monkey-patch:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from codex_auth import CodexClient
|
|
64
|
+
|
|
65
|
+
client = CodexClient() # browser / device auth
|
|
66
|
+
client = CodexClient(token="…") # existing token
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Async variant:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from codex_auth import AsyncCodexClient
|
|
73
|
+
client = AsyncCodexClient()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Disable auto-patch
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
export CODEX_AUTH_NO_PATCH=1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or in code:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
import codex_auth
|
|
86
|
+
codex_auth.init(auto_patch=False)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
A custom [httpx transport](https://www.python-httpx.org/advanced/transports/) intercepts OpenAI SDK requests to:
|
|
92
|
+
|
|
93
|
+
1. Rewrite URLs to the Codex backend (`chatgpt.com/backend-api/codex/responses`)
|
|
94
|
+
2. Convert `chat.completions` payloads to the Responses API format
|
|
95
|
+
3. Buffer SSE responses for non-streaming callers
|
|
96
|
+
4. Inject OAuth bearer tokens and refresh them transparently
|
|
97
|
+
|
|
98
|
+
Browser-based PKCE auth is used on desktop; device-code flow on headless/SSH.
|
|
99
|
+
|
|
100
|
+
## Token storage
|
|
101
|
+
|
|
102
|
+
`~/.codex-auth/auth.json` (mode `0600`). Override with `CODEX_AUTH_TOKEN` env var.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codex-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop-in OpenAI SDK patch for ChatGPT Codex OAuth authentication"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"openai>=1.0",
|
|
24
|
+
"httpx>=0.24",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
"pytest-asyncio>=0.21",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/codex_auth"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""codex-auth — drop-in Codex OAuth for the OpenAI Python SDK.
|
|
2
|
+
|
|
3
|
+
import codex_auth # patches openai automatically
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
client = OpenAI() # uses Codex OAuth, no API key needed
|
|
6
|
+
|
|
7
|
+
Or use the explicit client:
|
|
8
|
+
|
|
9
|
+
from codex_auth import CodexClient
|
|
10
|
+
client = CodexClient()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from .auth import authenticate
|
|
16
|
+
from .client import AsyncCodexClient, CodexClient
|
|
17
|
+
from .patch import apply_patch, remove_patch
|
|
18
|
+
from .tokens import VERSION as __version__ # noqa: N811
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"CodexClient",
|
|
23
|
+
"AsyncCodexClient",
|
|
24
|
+
"authenticate",
|
|
25
|
+
"apply_patch",
|
|
26
|
+
"remove_patch",
|
|
27
|
+
"init",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_auto_patched = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init(auto_patch: bool = True) -> None:
|
|
34
|
+
"""Called on import. Set ``CODEX_AUTH_NO_PATCH=1`` to suppress."""
|
|
35
|
+
global _auto_patched
|
|
36
|
+
if auto_patch and not _auto_patched:
|
|
37
|
+
if os.environ.get("CODEX_AUTH_NO_PATCH") == "1":
|
|
38
|
+
return
|
|
39
|
+
apply_patch()
|
|
40
|
+
_auto_patched = True
|
|
41
|
+
elif not auto_patch and _auto_patched:
|
|
42
|
+
remove_patch()
|
|
43
|
+
_auto_patched = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
init()
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Browser (PKCE) and device-code OAuth flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import webbrowser
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .tokens import (
|
|
18
|
+
CLIENT_ID,
|
|
19
|
+
ISSUER,
|
|
20
|
+
OAUTH_PORT,
|
|
21
|
+
OAUTH_SCOPES,
|
|
22
|
+
TOKEN_URL,
|
|
23
|
+
USER_AGENT,
|
|
24
|
+
AuthTokens,
|
|
25
|
+
TokenStore,
|
|
26
|
+
generate_pkce,
|
|
27
|
+
generate_state,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
33
|
+
_token_store = TokenStore()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _exchange_code(code: str, redirect_uri: str, verifier: str) -> dict[str, Any]:
|
|
37
|
+
r = httpx.post(
|
|
38
|
+
TOKEN_URL,
|
|
39
|
+
data={
|
|
40
|
+
"grant_type": "authorization_code",
|
|
41
|
+
"code": code,
|
|
42
|
+
"redirect_uri": redirect_uri,
|
|
43
|
+
"client_id": CLIENT_ID,
|
|
44
|
+
"code_verifier": verifier,
|
|
45
|
+
},
|
|
46
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
47
|
+
timeout=_TIMEOUT,
|
|
48
|
+
)
|
|
49
|
+
r.raise_for_status()
|
|
50
|
+
return r.json()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def refresh_access_token(refresh_token: str) -> dict[str, Any]:
|
|
54
|
+
r = httpx.post(
|
|
55
|
+
TOKEN_URL,
|
|
56
|
+
data={
|
|
57
|
+
"grant_type": "refresh_token",
|
|
58
|
+
"refresh_token": refresh_token,
|
|
59
|
+
"client_id": CLIENT_ID,
|
|
60
|
+
},
|
|
61
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
62
|
+
timeout=_TIMEOUT,
|
|
63
|
+
)
|
|
64
|
+
r.raise_for_status()
|
|
65
|
+
return r.json()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _authorize_url(redirect_uri: str, challenge: str, state: str) -> str:
|
|
69
|
+
params = urllib.parse.urlencode({
|
|
70
|
+
"response_type": "code",
|
|
71
|
+
"client_id": CLIENT_ID,
|
|
72
|
+
"redirect_uri": redirect_uri,
|
|
73
|
+
"scope": OAUTH_SCOPES,
|
|
74
|
+
"code_challenge": challenge,
|
|
75
|
+
"code_challenge_method": "S256",
|
|
76
|
+
"id_token_add_organizations": "true",
|
|
77
|
+
"codex_cli_simplified_flow": "true",
|
|
78
|
+
"state": state,
|
|
79
|
+
"originator": "codex-auth",
|
|
80
|
+
})
|
|
81
|
+
return f"{ISSUER}/oauth/authorize?{params}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_HTML_OK = (
|
|
85
|
+
"<!doctype html><html><head><title>Codex Auth</title>"
|
|
86
|
+
"<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;"
|
|
87
|
+
"align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}"
|
|
88
|
+
".c{text-align:center;padding:2rem}h1{margin-bottom:1rem}p{color:#b7b1b1}</style>"
|
|
89
|
+
"</head><body><div class='c'><h1>Authorization Successful</h1>"
|
|
90
|
+
"<p>You can close this window.</p></div>"
|
|
91
|
+
"<script>setTimeout(()=>window.close(),2000)</script></body></html>"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_HTML_ERR = (
|
|
95
|
+
"<!doctype html><html><head><title>Codex Auth</title>"
|
|
96
|
+
"<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;"
|
|
97
|
+
"align-items:center;height:100vh;margin:0;background:#131010;color:#f1ecec}"
|
|
98
|
+
".c{text-align:center;padding:2rem}h1{color:#fc533a;margin-bottom:1rem}"
|
|
99
|
+
"p{color:#b7b1b1}.e{color:#ff917b;font-family:monospace;margin-top:1rem;"
|
|
100
|
+
"padding:1rem;background:#3c140d;border-radius:.5rem}</style>"
|
|
101
|
+
"</head><body><div class='c'><h1>Authorization Failed</h1>"
|
|
102
|
+
"<p>An error occurred.</p><div class='e'>%s</div></div></body></html>"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
107
|
+
code: str | None = None
|
|
108
|
+
state: str | None = None
|
|
109
|
+
error: str | None = None
|
|
110
|
+
|
|
111
|
+
def do_GET(self) -> None:
|
|
112
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
113
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
114
|
+
|
|
115
|
+
if parsed.path != "/auth/callback":
|
|
116
|
+
self.send_response(404)
|
|
117
|
+
self.end_headers()
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if "error" in qs:
|
|
121
|
+
msg = qs.get("error_description", qs["error"])[0]
|
|
122
|
+
_CallbackHandler.error = msg
|
|
123
|
+
self._html(200, _HTML_ERR % msg)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
code = qs.get("code", [None])[0]
|
|
127
|
+
if not code:
|
|
128
|
+
_CallbackHandler.error = "Missing authorization code"
|
|
129
|
+
self._html(400, _HTML_ERR % "Missing authorization code")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
_CallbackHandler.code = code
|
|
133
|
+
_CallbackHandler.state = qs.get("state", [None])[0]
|
|
134
|
+
self._html(200, _HTML_OK)
|
|
135
|
+
|
|
136
|
+
def _html(self, status: int, body: str) -> None:
|
|
137
|
+
self.send_response(status)
|
|
138
|
+
self.send_header("Content-Type", "text/html")
|
|
139
|
+
self.end_headers()
|
|
140
|
+
self.wfile.write(body.encode())
|
|
141
|
+
|
|
142
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def browser_auth() -> AuthTokens:
|
|
147
|
+
verifier, challenge = generate_pkce()
|
|
148
|
+
state = generate_state()
|
|
149
|
+
redirect = f"http://localhost:{OAUTH_PORT}/auth/callback"
|
|
150
|
+
url = _authorize_url(redirect, challenge, state)
|
|
151
|
+
|
|
152
|
+
_CallbackHandler.code = _CallbackHandler.state = _CallbackHandler.error = None
|
|
153
|
+
|
|
154
|
+
srv = http.server.HTTPServer(("localhost", OAUTH_PORT), _CallbackHandler)
|
|
155
|
+
srv.timeout = 300
|
|
156
|
+
|
|
157
|
+
def serve() -> None:
|
|
158
|
+
while _CallbackHandler.code is None and _CallbackHandler.error is None:
|
|
159
|
+
srv.handle_request()
|
|
160
|
+
|
|
161
|
+
t = threading.Thread(target=serve, daemon=True)
|
|
162
|
+
t.start()
|
|
163
|
+
|
|
164
|
+
print(f"Opening browser for authentication...\nIf it doesn't open, visit:\n {url}")
|
|
165
|
+
webbrowser.open(url)
|
|
166
|
+
|
|
167
|
+
t.join(timeout=300)
|
|
168
|
+
srv.server_close()
|
|
169
|
+
|
|
170
|
+
if _CallbackHandler.error:
|
|
171
|
+
raise RuntimeError(f"OAuth error: {_CallbackHandler.error}")
|
|
172
|
+
if not _CallbackHandler.code:
|
|
173
|
+
raise TimeoutError("OAuth callback timed out")
|
|
174
|
+
if _CallbackHandler.state != state:
|
|
175
|
+
raise RuntimeError("OAuth state mismatch — possible CSRF")
|
|
176
|
+
|
|
177
|
+
raw = _exchange_code(_CallbackHandler.code, redirect, verifier)
|
|
178
|
+
auth = AuthTokens.from_response(raw)
|
|
179
|
+
_token_store.save(auth)
|
|
180
|
+
return auth
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def device_auth() -> AuthTokens:
|
|
184
|
+
r = httpx.post(
|
|
185
|
+
f"{ISSUER}/api/accounts/deviceauth/usercode",
|
|
186
|
+
json={"client_id": CLIENT_ID},
|
|
187
|
+
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
|
188
|
+
timeout=_TIMEOUT,
|
|
189
|
+
)
|
|
190
|
+
r.raise_for_status()
|
|
191
|
+
data = r.json()
|
|
192
|
+
|
|
193
|
+
device_id = data["device_auth_id"]
|
|
194
|
+
user_code = data["user_code"]
|
|
195
|
+
interval = max(int(data.get("interval", 5)), 1)
|
|
196
|
+
|
|
197
|
+
print(f"\nTo authenticate, visit: {ISSUER}/codex/device")
|
|
198
|
+
print(f"Enter code: {user_code}\n")
|
|
199
|
+
|
|
200
|
+
for _ in range(300 // interval):
|
|
201
|
+
time.sleep(interval + 3)
|
|
202
|
+
|
|
203
|
+
poll = httpx.post(
|
|
204
|
+
f"{ISSUER}/api/accounts/deviceauth/token",
|
|
205
|
+
json={"device_auth_id": device_id, "user_code": user_code},
|
|
206
|
+
headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
|
|
207
|
+
timeout=_TIMEOUT,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if poll.status_code in (403, 404):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if poll.is_success:
|
|
214
|
+
d = poll.json()
|
|
215
|
+
tok = httpx.post(
|
|
216
|
+
TOKEN_URL,
|
|
217
|
+
data={
|
|
218
|
+
"grant_type": "authorization_code",
|
|
219
|
+
"code": d["authorization_code"],
|
|
220
|
+
"redirect_uri": f"{ISSUER}/deviceauth/callback",
|
|
221
|
+
"client_id": CLIENT_ID,
|
|
222
|
+
"code_verifier": d["code_verifier"],
|
|
223
|
+
},
|
|
224
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
225
|
+
timeout=_TIMEOUT,
|
|
226
|
+
)
|
|
227
|
+
tok.raise_for_status()
|
|
228
|
+
auth = AuthTokens.from_response(tok.json())
|
|
229
|
+
_token_store.save(auth)
|
|
230
|
+
print("Authentication successful!")
|
|
231
|
+
return auth
|
|
232
|
+
|
|
233
|
+
raise RuntimeError(f"Device auth failed: HTTP {poll.status_code}")
|
|
234
|
+
|
|
235
|
+
raise TimeoutError("Device authentication timed out")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _has_display() -> bool:
|
|
239
|
+
if sys.platform in ("darwin", "win32"):
|
|
240
|
+
return True
|
|
241
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def authenticate(force: bool = False) -> AuthTokens:
|
|
245
|
+
"""Return valid tokens — from cache, refresh, or a fresh OAuth flow."""
|
|
246
|
+
if not force:
|
|
247
|
+
existing = _token_store.load()
|
|
248
|
+
if existing and existing.access_token:
|
|
249
|
+
if not existing.is_expired():
|
|
250
|
+
return existing
|
|
251
|
+
if existing.refresh_token:
|
|
252
|
+
try:
|
|
253
|
+
raw = refresh_access_token(existing.refresh_token)
|
|
254
|
+
auth = AuthTokens.from_response(
|
|
255
|
+
raw, existing.refresh_token, existing.account_id,
|
|
256
|
+
)
|
|
257
|
+
_token_store.save(auth)
|
|
258
|
+
return auth
|
|
259
|
+
except Exception:
|
|
260
|
+
log.debug("Token refresh failed, re-authenticating", exc_info=True)
|
|
261
|
+
|
|
262
|
+
if _has_display():
|
|
263
|
+
try:
|
|
264
|
+
return browser_auth()
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
print(f"Browser auth failed ({exc}), trying device flow...")
|
|
267
|
+
|
|
268
|
+
return device_auth()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import openai
|
|
5
|
+
|
|
6
|
+
from .auth import authenticate
|
|
7
|
+
from .patch import AsyncCodexTransport, CodexTransport
|
|
8
|
+
from .tokens import AuthTokens, TokenStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodexClient(openai.OpenAI):
|
|
12
|
+
"""``openai.OpenAI`` subclass that authenticates via Codex OAuth."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, token: str | None = None, **kwargs: object) -> None:
|
|
15
|
+
store = TokenStore()
|
|
16
|
+
auth = AuthTokens(access_token=token) if token else authenticate()
|
|
17
|
+
|
|
18
|
+
kwargs.setdefault(
|
|
19
|
+
"http_client",
|
|
20
|
+
httpx.Client(transport=CodexTransport(auth_tokens=auth, token_store=store)),
|
|
21
|
+
)
|
|
22
|
+
kwargs.setdefault("api_key", "codex-auth-dummy-key")
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncCodexClient(openai.AsyncOpenAI):
|
|
27
|
+
"""Async variant of :class:`CodexClient`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, token: str | None = None, **kwargs: object) -> None:
|
|
30
|
+
store = TokenStore()
|
|
31
|
+
auth = AuthTokens(access_token=token) if token else authenticate()
|
|
32
|
+
|
|
33
|
+
kwargs.setdefault(
|
|
34
|
+
"http_client",
|
|
35
|
+
httpx.AsyncClient(transport=AsyncCodexTransport(auth_tokens=auth, token_store=store)),
|
|
36
|
+
)
|
|
37
|
+
kwargs.setdefault("api_key", "codex-auth-dummy-key")
|
|
38
|
+
super().__init__(**kwargs)
|