oauth-cli-kit 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nanobot contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: oauth-cli-kit
3
+ Version: 0.1.1
4
+ Summary: Reusable OAuth 2.0 + PKCE helpers for CLI applications
5
+ Author: nanobot contributors
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: auth,cli,oauth,pkce
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: httpx>=0.25.0
16
+ Requires-Dist: platformdirs>=4.0.0
@@ -0,0 +1,3 @@
1
+ # oauth-cli-kit
2
+
3
+ A small, reusable OAuth 2.0 + PKCE helper focused on CLI apps. Ships with a default OpenAI Codex configuration but supports custom providers.
@@ -0,0 +1,13 @@
1
+ """oauth-cli-kit public API."""
2
+
3
+ from oauth_cli_kit.constants import OPENAI_CODEX_PROVIDER
4
+ from oauth_cli_kit.flow import get_token, login_oauth_interactive
5
+ from oauth_cli_kit.models import OAuthProviderConfig, OAuthToken
6
+
7
+ __all__ = [
8
+ "OPENAI_CODEX_PROVIDER",
9
+ "OAuthProviderConfig",
10
+ "OAuthToken",
11
+ "get_token",
12
+ "login_oauth_interactive",
13
+ ]
@@ -0,0 +1,31 @@
1
+ """Default provider settings and constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from oauth_cli_kit.models import OAuthProviderConfig
6
+
7
+ SUCCESS_HTML = (
8
+ "<!doctype html>"
9
+ "<html lang=\"en\">"
10
+ "<head>"
11
+ "<meta charset=\"utf-8\" />"
12
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"
13
+ "<title>Authentication successful</title>"
14
+ "</head>"
15
+ "<body>"
16
+ "<p>Authentication successful. Return to your terminal to continue.</p>"
17
+ "</body>"
18
+ "</html>"
19
+ )
20
+
21
+ OPENAI_CODEX_PROVIDER = OAuthProviderConfig(
22
+ client_id="app_EMoamEEZ73f0CkXaXp7hrann",
23
+ authorize_url="https://auth.openai.com/oauth/authorize",
24
+ token_url="https://auth.openai.com/oauth/token",
25
+ redirect_uri="http://localhost:1455/auth/callback",
26
+ scope="openid profile email offline_access",
27
+ jwt_claim_path="https://api.openai.com/auth",
28
+ account_id_claim="chatgpt_account_id",
29
+ default_originator="nanobot",
30
+ token_filename="codex.json",
31
+ )
@@ -0,0 +1,284 @@
1
+ """OAuth login and token management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ import threading
8
+ import time
9
+ import urllib.parse
10
+ import webbrowser
11
+ from typing import Callable
12
+
13
+ import httpx
14
+
15
+ from oauth_cli_kit.constants import OPENAI_CODEX_PROVIDER
16
+ from oauth_cli_kit.models import OAuthProviderConfig, OAuthToken
17
+ from oauth_cli_kit.pkce import (
18
+ _create_state,
19
+ _decode_account_id,
20
+ _generate_pkce,
21
+ _parse_authorization_input,
22
+ _parse_token_payload,
23
+ )
24
+ from oauth_cli_kit.server import _start_local_server
25
+ from oauth_cli_kit.storage import FileTokenStorage, TokenStorage, _FileLock
26
+
27
+
28
+ def _exchange_code_for_token_async(
29
+ code: str,
30
+ verifier: str,
31
+ provider: OAuthProviderConfig,
32
+ ) -> Callable[[], OAuthToken]:
33
+ async def _run() -> OAuthToken:
34
+ data = {
35
+ "grant_type": "authorization_code",
36
+ "client_id": provider.client_id,
37
+ "code": code,
38
+ "code_verifier": verifier,
39
+ "redirect_uri": provider.redirect_uri,
40
+ }
41
+ async with httpx.AsyncClient(timeout=30.0) as client:
42
+ response = await client.post(
43
+ provider.token_url,
44
+ data=data,
45
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
46
+ )
47
+ if response.status_code != 200:
48
+ raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
49
+
50
+ payload = response.json()
51
+ access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields")
52
+
53
+ account_id = _decode_account_id(access, provider.jwt_claim_path, provider.account_id_claim)
54
+ return OAuthToken(
55
+ access=access,
56
+ refresh=refresh,
57
+ expires=int(time.time() * 1000 + expires_in * 1000),
58
+ account_id=account_id,
59
+ )
60
+
61
+ return _run
62
+
63
+
64
+ def _refresh_token(refresh_token: str, provider: OAuthProviderConfig) -> OAuthToken:
65
+ data = {
66
+ "grant_type": "refresh_token",
67
+ "refresh_token": refresh_token,
68
+ "client_id": provider.client_id,
69
+ }
70
+ with httpx.Client(timeout=30.0) as client:
71
+ response = client.post(
72
+ provider.token_url,
73
+ data=data,
74
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
75
+ )
76
+ if response.status_code != 200:
77
+ raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}")
78
+
79
+ payload = response.json()
80
+ access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields")
81
+
82
+ account_id = _decode_account_id(access, provider.jwt_claim_path, provider.account_id_claim)
83
+ return OAuthToken(
84
+ access=access,
85
+ refresh=refresh,
86
+ expires=int(time.time() * 1000 + expires_in * 1000),
87
+ account_id=account_id,
88
+ )
89
+
90
+
91
+ def get_token(
92
+ provider: OAuthProviderConfig = OPENAI_CODEX_PROVIDER,
93
+ storage: TokenStorage | None = None,
94
+ min_ttl_seconds: int = 60,
95
+ ) -> OAuthToken:
96
+ """Get an available token (refresh if needed)."""
97
+ storage = storage or FileTokenStorage(token_filename=provider.token_filename)
98
+ token = storage.load()
99
+ if not token:
100
+ raise RuntimeError("OAuth credentials not found. Please run the login command.")
101
+
102
+ # Refresh early.
103
+ now_ms = int(time.time() * 1000)
104
+ if token.expires - now_ms > min_ttl_seconds * 1000:
105
+ return token
106
+
107
+ lock_path = storage.get_token_path().with_suffix(".lock")
108
+ with _FileLock(lock_path):
109
+ # Re-read to avoid stale token if another process refreshed it.
110
+ token = storage.load() or token
111
+ now_ms = int(time.time() * 1000)
112
+ if token.expires - now_ms > min_ttl_seconds * 1000:
113
+ return token
114
+ try:
115
+ refreshed = _refresh_token(token.refresh, provider)
116
+ storage.save(refreshed)
117
+ return refreshed
118
+ except Exception:
119
+ # If refresh fails, re-read the file to avoid false negatives.
120
+ latest = storage.load()
121
+ if latest and latest.expires - now_ms > 0:
122
+ return latest
123
+ raise
124
+
125
+
126
+ async def _read_stdin_line() -> str:
127
+ loop = asyncio.get_running_loop()
128
+ if hasattr(loop, "add_reader") and sys.stdin:
129
+ future: asyncio.Future[str] = loop.create_future()
130
+
131
+ def _on_readable() -> None:
132
+ line = sys.stdin.readline()
133
+ if not future.done():
134
+ future.set_result(line)
135
+
136
+ try:
137
+ loop.add_reader(sys.stdin, _on_readable)
138
+ except Exception:
139
+ return await loop.run_in_executor(None, sys.stdin.readline)
140
+
141
+ try:
142
+ return await future
143
+ finally:
144
+ try:
145
+ loop.remove_reader(sys.stdin)
146
+ except Exception:
147
+ pass
148
+
149
+ return await loop.run_in_executor(None, sys.stdin.readline)
150
+
151
+
152
+ async def _await_manual_input(print_fn: Callable[[str], None]) -> str:
153
+ print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]")
154
+ return await _read_stdin_line()
155
+
156
+
157
+ def login_oauth_interactive(
158
+ print_fn: Callable[[str], None],
159
+ prompt_fn: Callable[[str], str],
160
+ provider: OAuthProviderConfig = OPENAI_CODEX_PROVIDER,
161
+ originator: str | None = None,
162
+ storage: TokenStorage | None = None,
163
+ ) -> OAuthToken:
164
+ """Interactive login flow."""
165
+
166
+ async def _login_async() -> OAuthToken:
167
+ verifier, challenge = _generate_pkce()
168
+ state = _create_state()
169
+
170
+ params = {
171
+ "response_type": "code",
172
+ "client_id": provider.client_id,
173
+ "redirect_uri": provider.redirect_uri,
174
+ "scope": provider.scope,
175
+ "code_challenge": challenge,
176
+ "code_challenge_method": "S256",
177
+ "state": state,
178
+ "id_token_add_organizations": "true",
179
+ "codex_cli_simplified_flow": "true",
180
+ "originator": originator or provider.default_originator,
181
+ }
182
+ url = f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
183
+
184
+ loop = asyncio.get_running_loop()
185
+ code_future: asyncio.Future[str] = loop.create_future()
186
+
187
+ def _notify(code_value: str) -> None:
188
+ if code_future.done():
189
+ return
190
+ loop.call_soon_threadsafe(code_future.set_result, code_value)
191
+
192
+ server, server_error = _start_local_server(state, on_code=_notify)
193
+ print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
194
+ print_fn(url)
195
+ try:
196
+ webbrowser.open(url)
197
+ except Exception:
198
+ pass
199
+
200
+ if not server and server_error:
201
+ print_fn(
202
+ "[yellow]"
203
+ f"Local callback server could not start ({server_error}). "
204
+ "You will need to paste the callback URL or authorization code."
205
+ "[/yellow]"
206
+ )
207
+
208
+ code: str | None = None
209
+ try:
210
+ if server:
211
+ print_fn("[dim]Waiting for browser callback...[/dim]")
212
+
213
+ tasks: list[asyncio.Task[object]] = []
214
+ callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
215
+ tasks.append(callback_task)
216
+ manual_task = asyncio.create_task(_await_manual_input(print_fn))
217
+ tasks.append(manual_task)
218
+
219
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
220
+ for task in pending:
221
+ task.cancel()
222
+
223
+ for task in done:
224
+ try:
225
+ result = task.result()
226
+ except asyncio.TimeoutError:
227
+ result = None
228
+ if not result:
229
+ continue
230
+ if task is manual_task:
231
+ parsed_code, parsed_state = _parse_authorization_input(result)
232
+ if parsed_state and parsed_state != state:
233
+ raise RuntimeError("State validation failed.")
234
+ code = parsed_code
235
+ else:
236
+ code = result
237
+ if code:
238
+ break
239
+
240
+ if not code:
241
+ prompt = "Please paste the callback URL or authorization code:"
242
+ raw = await loop.run_in_executor(None, prompt_fn, prompt)
243
+ parsed_code, parsed_state = _parse_authorization_input(raw)
244
+ if parsed_state and parsed_state != state:
245
+ raise RuntimeError("State validation failed.")
246
+ code = parsed_code
247
+
248
+ if not code:
249
+ raise RuntimeError("Authorization code not found.")
250
+
251
+ print_fn("[dim]Exchanging authorization code for tokens...[/dim]")
252
+ token = await _exchange_code_for_token_async(code, verifier, provider)()
253
+ (storage or FileTokenStorage(token_filename=provider.token_filename)).save(token)
254
+ return token
255
+ finally:
256
+ if server:
257
+ server.shutdown()
258
+ server.server_close()
259
+
260
+ try:
261
+ asyncio.get_running_loop()
262
+ except RuntimeError:
263
+ return asyncio.run(_login_async())
264
+
265
+ result: list[OAuthToken] = []
266
+ error: list[Exception] = []
267
+
268
+ def _runner() -> None:
269
+ try:
270
+ result.append(asyncio.run(_login_async()))
271
+ except Exception as exc:
272
+ error.append(exc)
273
+
274
+ thread = threading.Thread(target=_runner)
275
+ thread.start()
276
+ thread.join()
277
+ if error:
278
+ raise error[0]
279
+ return result[0]
280
+
281
+
282
+ # Backward-compatible aliases.
283
+ login_codex_oauth_interactive = login_oauth_interactive
284
+ get_codex_token = get_token
@@ -0,0 +1,34 @@
1
+ """OAuth data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class OAuthProviderConfig:
10
+ """Configuration for an OAuth 2.0 provider."""
11
+
12
+ client_id: str
13
+ authorize_url: str
14
+ token_url: str
15
+ redirect_uri: str
16
+ scope: str
17
+ jwt_claim_path: str | None = None
18
+ account_id_claim: str | None = None
19
+ default_originator: str = "oauth-cli-kit"
20
+ token_filename: str = "oauth.json"
21
+
22
+
23
+ @dataclass
24
+ class OAuthToken:
25
+ """OAuth token data structure."""
26
+
27
+ access: str
28
+ refresh: str
29
+ expires: int
30
+ account_id: str | None = None
31
+
32
+
33
+ # Backward-compatible alias for earlier Codex naming.
34
+ CodexToken = OAuthToken
@@ -0,0 +1,81 @@
1
+ """PKCE and authorization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import urllib.parse
10
+ from typing import Any
11
+
12
+
13
+ def _base64url(data: bytes) -> str:
14
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
15
+
16
+
17
+ def _decode_base64url(data: str) -> bytes:
18
+ padding = "=" * (-len(data) % 4)
19
+ return base64.urlsafe_b64decode(data + padding)
20
+
21
+
22
+ def _generate_pkce() -> tuple[str, str]:
23
+ verifier = _base64url(os.urandom(32))
24
+ challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
25
+ return verifier, challenge
26
+
27
+
28
+ def _create_state() -> str:
29
+ return _base64url(os.urandom(16))
30
+
31
+
32
+ def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]:
33
+ value = raw.strip()
34
+ if not value:
35
+ return None, None
36
+ try:
37
+ url = urllib.parse.urlparse(value)
38
+ qs = urllib.parse.parse_qs(url.query)
39
+ code = qs.get("code", [None])[0]
40
+ state = qs.get("state", [None])[0]
41
+ if code:
42
+ return code, state
43
+ except Exception:
44
+ pass
45
+
46
+ if "#" in value:
47
+ parts = value.split("#", 1)
48
+ return parts[0] or None, parts[1] or None
49
+
50
+ if "code=" in value:
51
+ qs = urllib.parse.parse_qs(value)
52
+ return qs.get("code", [None])[0], qs.get("state", [None])[0]
53
+
54
+ return value, None
55
+
56
+
57
+ def _decode_account_id(
58
+ access_token: str,
59
+ jwt_claim_path: str | None,
60
+ account_id_claim: str | None,
61
+ ) -> str | None:
62
+ if not jwt_claim_path or not account_id_claim:
63
+ return None
64
+ parts = access_token.split(".")
65
+ if len(parts) != 3:
66
+ raise ValueError("Invalid JWT token")
67
+ payload = json.loads(_decode_base64url(parts[1]).decode("utf-8"))
68
+ auth = payload.get(jwt_claim_path) or {}
69
+ account_id = auth.get(account_id_claim)
70
+ if not account_id:
71
+ return None
72
+ return str(account_id)
73
+
74
+
75
+ def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]:
76
+ access = payload.get("access_token")
77
+ refresh = payload.get("refresh_token")
78
+ expires_in = payload.get("expires_in")
79
+ if not access or not refresh or not isinstance(expires_in, int):
80
+ raise RuntimeError(missing_message)
81
+ return access, refresh, expires_in
@@ -0,0 +1,115 @@
1
+ """Local OAuth callback server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import threading
7
+ import urllib.parse
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Any, Callable
10
+
11
+ from oauth_cli_kit.constants import SUCCESS_HTML
12
+
13
+
14
+ class _OAuthHandler(BaseHTTPRequestHandler):
15
+ """Local callback HTTP handler."""
16
+
17
+ server_version = "OAuthCliKit/1.0"
18
+ protocol_version = "HTTP/1.1"
19
+
20
+ def do_GET(self) -> None: # noqa: N802
21
+ try:
22
+ url = urllib.parse.urlparse(self.path)
23
+ if url.path != "/auth/callback":
24
+ self.send_response(404)
25
+ self.end_headers()
26
+ self.wfile.write(b"Not found")
27
+ return
28
+
29
+ qs = urllib.parse.parse_qs(url.query)
30
+ code = qs.get("code", [None])[0]
31
+ state = qs.get("state", [None])[0]
32
+
33
+ if state != self.server.expected_state:
34
+ self.send_response(400)
35
+ self.end_headers()
36
+ self.wfile.write(b"State mismatch")
37
+ return
38
+
39
+ if not code:
40
+ self.send_response(400)
41
+ self.end_headers()
42
+ self.wfile.write(b"Missing code")
43
+ return
44
+
45
+ self.server.code = code
46
+ try:
47
+ if getattr(self.server, "on_code", None):
48
+ self.server.on_code(code)
49
+ except Exception:
50
+ pass
51
+ body = SUCCESS_HTML.encode("utf-8")
52
+ self.send_response(200)
53
+ self.send_header("Content-Type", "text/html; charset=utf-8")
54
+ self.send_header("Content-Length", str(len(body)))
55
+ self.send_header("Connection", "close")
56
+ self.end_headers()
57
+ self.wfile.write(body)
58
+ try:
59
+ self.wfile.flush()
60
+ except Exception:
61
+ pass
62
+ self.close_connection = True
63
+ except Exception:
64
+ self.send_response(500)
65
+ self.end_headers()
66
+ self.wfile.write(b"Internal error")
67
+
68
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
69
+ # Suppress default logs to avoid noisy output.
70
+ return
71
+
72
+
73
+ class _OAuthServer(HTTPServer):
74
+ """OAuth callback server with state."""
75
+
76
+ def __init__(
77
+ self,
78
+ server_address: tuple[str, int],
79
+ expected_state: str,
80
+ on_code: Callable[[str], None] | None = None,
81
+ ):
82
+ super().__init__(server_address, _OAuthHandler)
83
+ self.expected_state = expected_state
84
+ self.code: str | None = None
85
+ self.on_code = on_code
86
+
87
+
88
+ def _start_local_server(
89
+ state: str,
90
+ on_code: Callable[[str], None] | None = None,
91
+ ) -> tuple[_OAuthServer | None, str | None]:
92
+ """Start a local OAuth callback server on the first available localhost address."""
93
+ try:
94
+ addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM)
95
+ except OSError as exc:
96
+ return None, f"Failed to resolve localhost: {exc}"
97
+
98
+ last_error: OSError | None = None
99
+ for family, _socktype, _proto, _canonname, sockaddr in addrinfos:
100
+ try:
101
+ # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1.
102
+ class _AddrOAuthServer(_OAuthServer):
103
+ address_family = family
104
+
105
+ server = _AddrOAuthServer(sockaddr, state, on_code=on_code)
106
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
107
+ thread.start()
108
+ return server, None
109
+ except OSError as exc:
110
+ last_error = exc
111
+ continue
112
+
113
+ if last_error:
114
+ return None, f"Local callback server failed to start: {last_error}"
115
+ return None, "Local callback server failed to start: unknown error"
@@ -0,0 +1,166 @@
1
+ """Token storage helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Protocol
10
+
11
+ from platformdirs import user_data_dir
12
+
13
+ from oauth_cli_kit.models import OAuthToken
14
+
15
+
16
+ class TokenStorage(Protocol):
17
+ """Abstract token storage interface."""
18
+
19
+ def load(self) -> OAuthToken | None:
20
+ ...
21
+
22
+ def save(self, token: OAuthToken) -> None:
23
+ ...
24
+
25
+ def get_token_path(self) -> Path:
26
+ ...
27
+
28
+
29
+ def _get_token_path(
30
+ token_filename: str,
31
+ app_name: str,
32
+ data_dir: Path | None = None,
33
+ ) -> Path:
34
+ override = os.environ.get("OAUTH_CLI_KIT_TOKEN_PATH")
35
+ if override:
36
+ return Path(override)
37
+ base_dir = data_dir or Path(user_data_dir(app_name, appauthor=False))
38
+ return base_dir / "auth" / token_filename
39
+
40
+
41
+ def _load_token_file(path: Path) -> OAuthToken | None:
42
+ if not path.exists():
43
+ return None
44
+ try:
45
+ data = json.loads(path.read_text(encoding="utf-8"))
46
+ return OAuthToken(
47
+ access=data["access"],
48
+ refresh=data["refresh"],
49
+ expires=int(data["expires"]),
50
+ account_id=data.get("account_id"),
51
+ )
52
+ except Exception:
53
+ return None
54
+
55
+
56
+ def _save_token_file(path: Path, token: OAuthToken) -> None:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ payload = {
59
+ "access": token.access,
60
+ "refresh": token.refresh,
61
+ "expires": token.expires,
62
+ }
63
+ if token.account_id:
64
+ payload["account_id"] = token.account_id
65
+ path.write_text(
66
+ json.dumps(payload, ensure_ascii=True, indent=2),
67
+ encoding="utf-8",
68
+ )
69
+ try:
70
+ os.chmod(path, 0o600)
71
+ except Exception:
72
+ # Ignore permission setting failures.
73
+ pass
74
+
75
+
76
+ def _try_import_codex_cli_token(path: Path) -> OAuthToken | None:
77
+ codex_path = Path.home() / ".codex" / "auth.json"
78
+ if not codex_path.exists():
79
+ return None
80
+ try:
81
+ data = json.loads(codex_path.read_text(encoding="utf-8"))
82
+ tokens = data.get("tokens") or {}
83
+ access = tokens.get("access_token")
84
+ refresh = tokens.get("refresh_token")
85
+ account_id = tokens.get("account_id")
86
+ if not access or not refresh or not account_id:
87
+ return None
88
+ try:
89
+ mtime = codex_path.stat().st_mtime
90
+ expires = int(mtime * 1000 + 60 * 60 * 1000)
91
+ except Exception:
92
+ expires = int(time.time() * 1000 + 60 * 60 * 1000)
93
+ token = OAuthToken(
94
+ access=str(access),
95
+ refresh=str(refresh),
96
+ expires=expires,
97
+ account_id=str(account_id),
98
+ )
99
+ _save_token_file(path, token)
100
+ return token
101
+ except Exception:
102
+ return None
103
+
104
+
105
+ class FileTokenStorage:
106
+ """File-based token storage."""
107
+
108
+ def __init__(
109
+ self,
110
+ token_filename: str = "oauth.json",
111
+ app_name: str = "oauth-cli-kit",
112
+ data_dir: Path | None = None,
113
+ import_codex_cli: bool = True,
114
+ ) -> None:
115
+ self._token_filename = token_filename
116
+ self._app_name = app_name
117
+ self._data_dir = data_dir
118
+ self._import_codex_cli = import_codex_cli
119
+
120
+ def get_token_path(self) -> Path:
121
+ return _get_token_path(self._token_filename, self._app_name, self._data_dir)
122
+
123
+ def load(self) -> OAuthToken | None:
124
+ path = self.get_token_path()
125
+ token = _load_token_file(path)
126
+ if token:
127
+ return token
128
+ if self._import_codex_cli:
129
+ return _try_import_codex_cli_token(path)
130
+ return None
131
+
132
+ def save(self, token: OAuthToken) -> None:
133
+ _save_token_file(self.get_token_path(), token)
134
+
135
+
136
+ class _FileLock:
137
+ """Simple file lock to reduce concurrent refreshes."""
138
+
139
+ def __init__(self, path: Path):
140
+ self._path = path
141
+ self._fp = None
142
+
143
+ def __enter__(self) -> "_FileLock":
144
+ self._path.parent.mkdir(parents=True, exist_ok=True)
145
+ self._fp = open(self._path, "a+")
146
+ try:
147
+ import fcntl
148
+
149
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
150
+ except Exception:
151
+ # Non-POSIX or failed lock: continue without locking.
152
+ pass
153
+ return self
154
+
155
+ def __exit__(self, exc_type, exc, tb) -> None:
156
+ try:
157
+ import fcntl
158
+
159
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
160
+ except Exception:
161
+ pass
162
+ try:
163
+ if self._fp:
164
+ self._fp.close()
165
+ except Exception:
166
+ pass
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "oauth-cli-kit"
3
+ dynamic = ["version"]
4
+ description = "Reusable OAuth 2.0 + PKCE helpers for CLI applications"
5
+ requires-python = ">=3.11"
6
+ license = {text = "MIT"}
7
+ authors = [{name = "nanobot contributors"}]
8
+ keywords = ["oauth", "pkce", "cli", "auth"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ ]
16
+
17
+ dependencies = [
18
+ "httpx>=0.25.0",
19
+ "platformdirs>=4.0.0",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling", "hatch-vcs"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["oauth_cli_kit"]
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = [
31
+ "oauth_cli_kit/",
32
+ "README.md",
33
+ "LICENSE",
34
+ ]
35
+
36
+ [tool.hatch.version]
37
+ source = "vcs"