chatgpt-auth 0.1.0__py3-none-any.whl

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,36 @@
1
+ """chatgpt-auth: Sign in with ChatGPT for CLI and Python applications."""
2
+
3
+ from .credentials import Credentials
4
+ from .errors import (
5
+ AuthError,
6
+ ChatGPTAuthError,
7
+ ConnectionChangedError,
8
+ LoginTimeoutError,
9
+ NetworkError,
10
+ PlanPermissionError,
11
+ ReauthorizationRequiredError,
12
+ SignInRequiredError,
13
+ StorageError,
14
+ )
15
+ from .service import login, logout, status
16
+ from .types import AuthStatus, LogoutResult
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "AuthError",
22
+ "AuthStatus",
23
+ "ChatGPTAuthError",
24
+ "ConnectionChangedError",
25
+ "Credentials",
26
+ "LoginTimeoutError",
27
+ "LogoutResult",
28
+ "NetworkError",
29
+ "PlanPermissionError",
30
+ "ReauthorizationRequiredError",
31
+ "SignInRequiredError",
32
+ "StorageError",
33
+ "login",
34
+ "logout",
35
+ "status",
36
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,196 @@
1
+ """Loopback callback listener and pasted-URL handling for sign-in.
2
+
3
+ No storage and no UI framework: the caller injects the warning sink and
4
+ the input stream so the CLI and tests can differ.
5
+ """
6
+
7
+ import os
8
+ import secrets
9
+ import select
10
+ import sys
11
+ import threading
12
+ import time
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import parse_qsl, urlsplit
15
+
16
+ from .errors import AuthError
17
+
18
+ POLL_INTERVAL_SECONDS = 0.1
19
+
20
+
21
+ def callback_query(redirect_uri, text, *, expected_state=None):
22
+ """Turn a pasted callback URL into a query dict, validated for one attempt.
23
+
24
+ The URL's scheme, host, port, and path must equal the attempt's redirect
25
+ URI, and every query parameter must appear exactly once. Never includes
26
+ the pasted text in errors: it may carry an authorization code.
27
+ """
28
+ try:
29
+ pasted = urlsplit(text.strip())
30
+ pasted_port = pasted.port
31
+ except ValueError:
32
+ raise AuthError("invalid_callback_url") from None
33
+ expected = urlsplit(redirect_uri)
34
+ if (
35
+ pasted.scheme,
36
+ pasted.hostname,
37
+ pasted_port,
38
+ pasted.path,
39
+ ) != (
40
+ expected.scheme,
41
+ expected.hostname,
42
+ expected.port,
43
+ expected.path,
44
+ ):
45
+ raise AuthError("invalid_callback_url")
46
+ pairs = parse_qsl(pasted.query, keep_blank_values=True)
47
+ keys = [key for key, _ in pairs]
48
+ if len(set(keys)) != len(keys):
49
+ raise AuthError("invalid_callback_url")
50
+ query = dict(pairs)
51
+ if expected_state is not None and not secrets.compare_digest(
52
+ query.get("state", ""), expected_state
53
+ ):
54
+ raise AuthError("invalid_state")
55
+ if not (query.get("code") or query.get("error")):
56
+ # Neither a grant nor a decline: no usable result exists, so this
57
+ # must not end the attempt (a truncated paste stays recoverable).
58
+ raise AuthError("invalid_callback_url")
59
+ return query
60
+
61
+
62
+ class CallbackListener:
63
+ """One browser authorization result, received on a private loopback listener."""
64
+
65
+ def __init__(self, port=0):
66
+ self.result = None
67
+ self.expected_state = None
68
+ self._received = threading.Event()
69
+ self._lock = threading.Lock()
70
+ listener = self
71
+
72
+ class Handler(BaseHTTPRequestHandler):
73
+ def log_message(self, format, *args):
74
+ pass # Callback URLs contain a credential: do not log them.
75
+
76
+ def do_GET(self):
77
+ host = self.headers.get("Host")
78
+ expected_state = listener.expected_state
79
+ query = None
80
+ if expected_state is not None:
81
+ try:
82
+ query = callback_query(
83
+ listener.redirect_uri,
84
+ f"http://{host}{self.path}",
85
+ expected_state=expected_state,
86
+ )
87
+ except (AuthError, ValueError):
88
+ query = None
89
+ body = (
90
+ "Authorization received. Return to your terminal for the result."
91
+ if query is not None
92
+ else "This authorization result could not be verified."
93
+ ).encode()
94
+ self.send_response(200 if query is not None else 400)
95
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
96
+ self.send_header("Content-Length", str(len(body)))
97
+ self.send_header("Cache-Control", "no-store")
98
+ self.send_header("Referrer-Policy", "no-referrer")
99
+ self.end_headers()
100
+ self.wfile.write(body)
101
+ if query is not None:
102
+ listener.submit(query)
103
+
104
+ self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
105
+ self.server.daemon_threads = True
106
+ self.host = f"127.0.0.1:{self.server.server_port}"
107
+ self.redirect_uri = f"http://{self.host}/auth/callback"
108
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
109
+
110
+ def start(self, state):
111
+ self.expected_state = state
112
+ self.thread.start()
113
+
114
+ def submit(self, query):
115
+ """Record the attempt's result; only the first caller wins."""
116
+ with self._lock:
117
+ if self.result is not None:
118
+ return False
119
+ self.result = query
120
+ self._received.set()
121
+ return True
122
+
123
+ def wait(self, timeout):
124
+ """Wait for the authorization result; returns None on timeout."""
125
+ if self._received.wait(timeout):
126
+ return self.result
127
+ return None
128
+
129
+ def close(self):
130
+ if self.thread.is_alive():
131
+ self.server.shutdown()
132
+ self.thread.join()
133
+ self.server.server_close()
134
+
135
+
136
+ def wait_for_result(listener, deadline, stream=None, warn=None):
137
+ """Wait for the HTTP callback or a pasted callback URL; first wins.
138
+
139
+ A pasted URL is only read when ``stream`` is interactive (a TTY); any
140
+ input that fails validation is reported through ``warn`` and the wait
141
+ continues. On non-interactive streams only the HTTP callback is watched.
142
+ No background thread is created. Byte-wise reads stop exactly at the
143
+ newline that completes the pasted URL, so input queued behind it is
144
+ left in the terminal for the shell, not swallowed by sign-in.
145
+ """
146
+ warn = warn or (lambda message: print(message, file=sys.stderr))
147
+ try:
148
+ fd = stream.fileno() if stream is not None else -1
149
+ interactive = fd >= 0 and stream.isatty()
150
+ except (OSError, ValueError, AttributeError):
151
+ interactive = False
152
+ if not interactive:
153
+ return listener.wait(max(0.0, deadline - time.monotonic()))
154
+ pending = bytearray()
155
+ while True:
156
+ result = listener.wait(0)
157
+ if result is not None:
158
+ return result
159
+ remaining = deadline - time.monotonic()
160
+ if remaining <= 0:
161
+ return None
162
+ readable, _, _ = select.select(
163
+ [fd], [], [], min(remaining, POLL_INTERVAL_SECONDS)
164
+ )
165
+ if not readable:
166
+ continue
167
+ byte = os.read(fd, 1)
168
+ if not byte:
169
+ # EOF: only the loopback callback can still complete this
170
+ return listener.wait(max(0.0, deadline - time.monotonic()))
171
+ if byte != b"\n":
172
+ pending += byte
173
+ if len(pending) > 65536:
174
+ warn(
175
+ "Rejected pasted URL (too long). Paste the full address "
176
+ "the browser was redirected to."
177
+ )
178
+ pending.clear()
179
+ continue
180
+ text = pending.decode("utf-8", "replace").strip()
181
+ pending.clear()
182
+ if not text:
183
+ continue
184
+ try:
185
+ query = callback_query(
186
+ listener.redirect_uri, text, expected_state=listener.expected_state
187
+ )
188
+ except AuthError as exc:
189
+ warn(
190
+ f"Rejected pasted URL ({exc}). Paste the full address the "
191
+ "browser was redirected to."
192
+ )
193
+ continue
194
+ if listener.submit(query):
195
+ return query
196
+ return listener.wait(0)
chatgpt_auth/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ """The ``chatgpt-auth`` command line: login, status, token, refresh, logout."""
2
+
3
+ import sys
4
+ import time
5
+ import webbrowser
6
+ from datetime import UTC, datetime
7
+
8
+ import click
9
+
10
+ from . import service
11
+ from .credentials import Credentials
12
+ from .errors import (
13
+ ChatGPTAuthError,
14
+ ConnectionChangedError,
15
+ LoginTimeoutError,
16
+ NetworkError,
17
+ PlanPermissionError,
18
+ ReauthorizationRequiredError,
19
+ SignInRequiredError,
20
+ StorageError,
21
+ )
22
+ from .oauth import (
23
+ DEFAULT_AGENT_NAME,
24
+ DEFAULT_TIMEOUT_SECONDS,
25
+ MAX_TIMEOUT_SECONDS,
26
+ )
27
+
28
+ USAGE_URL = "https://chatgpt.com/settings/usage"
29
+
30
+
31
+ @click.group()
32
+ @click.option(
33
+ "--credentials",
34
+ "credentials_path",
35
+ metavar="PATH",
36
+ default=None,
37
+ help="Credentials file to use (overrides CHATGPT_AUTH_CREDENTIALS)",
38
+ )
39
+ @click.pass_context
40
+ def main(ctx, credentials_path):
41
+ """Manage a Sign in with ChatGPT connection."""
42
+ ctx.obj = credentials_path
43
+
44
+
45
+ def _path(ctx):
46
+ return ctx.obj
47
+
48
+
49
+ def _auth_message(exc) -> str:
50
+ if isinstance(exc, SignInRequiredError):
51
+ return "Not signed in. Run: chatgpt-auth login"
52
+ if isinstance(exc, ReauthorizationRequiredError):
53
+ return str(exc) or "Sign-in expired or was revoked. Run: chatgpt-auth login"
54
+ if isinstance(exc, PlanPermissionError):
55
+ return (
56
+ "ChatGPT plan usage was not granted. Check your usage sharing "
57
+ f"settings: {USAGE_URL}"
58
+ )
59
+ if isinstance(exc, ConnectionChangedError):
60
+ return str(exc)
61
+ if isinstance(exc, (StorageError, NetworkError, LoginTimeoutError)):
62
+ return str(exc)
63
+ code = str(exc)
64
+ guidance = {
65
+ "sign_in_declined": "Sign-in was declined in the browser.",
66
+ "expired_sign_in": "The sign-in attempt expired. Run login again.",
67
+ "connection_changed": (
68
+ "The connection changed while signing in. Run chatgpt-auth login again."
69
+ ),
70
+ "account_mismatch": (
71
+ "A different ChatGPT account signed in. Run login again or use "
72
+ "--replace to switch accounts."
73
+ ),
74
+ }
75
+ return guidance.get(
76
+ code,
77
+ f"Sign-in failed: {code}. Run chatgpt-auth login to try again.",
78
+ )
79
+
80
+
81
+ def _show_manual_url(url, browser_failed):
82
+ if browser_failed:
83
+ click.echo("Could not open a browser automatically.", err=True)
84
+ click.echo("Open this URL to sign in with ChatGPT and approve plan usage:")
85
+ click.echo(url)
86
+ if sys.stdin.isatty():
87
+ click.echo(
88
+ "After approving, the browser is redirected to a local address. "
89
+ "If the redirect does not reach this machine, copy the full URL "
90
+ "from the address bar and paste it here."
91
+ )
92
+ else:
93
+ click.echo(
94
+ "Pasted URLs are not accepted in this session; complete the "
95
+ "sign-in in a browser that can reach this machine."
96
+ )
97
+
98
+
99
+ def _fail(exc):
100
+ raise click.ClickException(_auth_message(exc)) from exc
101
+
102
+
103
+ @main.command()
104
+ @click.option(
105
+ "--replace",
106
+ is_flag=True,
107
+ help="Replace the current connection with a new sign-in",
108
+ )
109
+ @click.option(
110
+ "--manual",
111
+ is_flag=True,
112
+ help="Show the sign-in URL instead of opening a browser; the loopback "
113
+ "callback or a pasted callback URL completes sign-in",
114
+ )
115
+ @click.option(
116
+ "--timeout",
117
+ type=int,
118
+ default=DEFAULT_TIMEOUT_SECONDS,
119
+ show_default=True,
120
+ help="Seconds to wait for browser sign-in (1-600)",
121
+ )
122
+ @click.option("--port", type=int, default=0, help="Callback port (0 = automatic)")
123
+ @click.option(
124
+ "--agent-name",
125
+ default=DEFAULT_AGENT_NAME,
126
+ show_default=True,
127
+ help="Application name sent on first-time registration",
128
+ )
129
+ @click.pass_context
130
+ def login(ctx, replace, manual, timeout, port, agent_name):
131
+ """Sign in with ChatGPT and save the OAuth connection"""
132
+ if not 1 <= timeout <= MAX_TIMEOUT_SECONDS:
133
+ raise click.UsageError("--timeout must be between 1 and 600")
134
+ if not (port == 0 or 1024 <= port <= 65535):
135
+ raise click.UsageError("--port must be 0 or between 1024 and 65535")
136
+ try:
137
+ if not manual:
138
+ click.echo(
139
+ "Opening a browser to sign in with ChatGPT and approve plan usage...",
140
+ err=True,
141
+ )
142
+ credentials = service.login(
143
+ _path(ctx),
144
+ agent_name=agent_name,
145
+ replace=replace,
146
+ manual=manual,
147
+ timeout=timeout,
148
+ port=port,
149
+ open_browser=webbrowser.open,
150
+ show_url=_show_manual_url,
151
+ input_stream=sys.stdin,
152
+ warn=lambda message: click.echo(message, err=True),
153
+ )
154
+ except ChatGPTAuthError as exc:
155
+ _fail(exc)
156
+ except KeyboardInterrupt:
157
+ click.echo("Sign-in aborted.", err=True)
158
+ ctx.exit(130)
159
+ account = credentials.email or credentials.subject
160
+ click.echo(f"Signed in and verified ChatGPT plan access for {account}.")
161
+
162
+
163
+ @main.command()
164
+ @click.pass_context
165
+ def status(ctx):
166
+ """Show the local state of the stored connection"""
167
+ try:
168
+ report = service.status(_path(ctx))
169
+ except ChatGPTAuthError as exc:
170
+ _fail(exc)
171
+ except KeyboardInterrupt:
172
+ ctx.exit(130)
173
+ if not report.connected:
174
+ click.echo("Not signed in.")
175
+ if report.email:
176
+ click.echo(f"Last account: {report.email}")
177
+ click.echo(f"Storage: {report.path}")
178
+ return
179
+ click.echo(
180
+ "Signed in." if report.state == "active" else "Signed in (refresh needed)."
181
+ )
182
+ if report.email:
183
+ click.echo(
184
+ f"Account: {report.email}" + (f" ({report.name})" if report.name else "")
185
+ )
186
+ elif report.subject:
187
+ click.echo(f"Account: {report.subject}")
188
+ expires = datetime.fromtimestamp(report.expires_at, tz=UTC)
189
+ remaining = int(report.expires_at - time.time())
190
+ if remaining > 0:
191
+ click.echo(
192
+ f"Access token expires: {expires.isoformat()} (in {remaining // 60} min)"
193
+ )
194
+ else:
195
+ click.echo(f"Access token expired: {expires.isoformat()}")
196
+ click.echo(f"Storage: {report.path}")
197
+
198
+
199
+ @main.command(name="print-access-token")
200
+ @click.pass_context
201
+ def print_access_token(ctx):
202
+ """Print a valid access token (stdout only, nothing else)"""
203
+ try:
204
+ token = Credentials.load(_path(ctx)).get_access_token()
205
+ except ChatGPTAuthError as exc:
206
+ _fail(exc)
207
+ except KeyboardInterrupt:
208
+ ctx.exit(130)
209
+ click.echo(token)
210
+
211
+
212
+ @main.command()
213
+ @click.pass_context
214
+ def refresh(ctx):
215
+ """Force a token refresh now; the token is not displayed"""
216
+ try:
217
+ Credentials.load(_path(ctx)).refresh()
218
+ except ChatGPTAuthError as exc:
219
+ _fail(exc)
220
+ except KeyboardInterrupt:
221
+ ctx.exit(130)
222
+ click.echo("Access token refreshed and saved.")
223
+
224
+
225
+ @main.command()
226
+ @click.pass_context
227
+ def logout(ctx):
228
+ """Sign out: remove local tokens and try to revoke the session"""
229
+ try:
230
+ result = service.logout(_path(ctx))
231
+ except ChatGPTAuthError as exc:
232
+ _fail(exc)
233
+ except KeyboardInterrupt:
234
+ ctx.exit(130)
235
+ if not result.was_connected:
236
+ click.echo("Not signed in.")
237
+ return
238
+ if result.remote_revoked:
239
+ click.echo("Signed out. The ChatGPT session was revoked remotely.")
240
+ else:
241
+ click.echo("Signed out locally.")
242
+ click.echo(
243
+ "Could not confirm the remote session was revoked. To "
244
+ "disconnect this app completely, remove it in ChatGPT under "
245
+ "Settings > Security and login > Sign in with ChatGPT.",
246
+ err=True,
247
+ )
chatgpt_auth/config.py ADDED
@@ -0,0 +1,97 @@
1
+ """Credentials file location: resolution order, naming rules, validation.
2
+
3
+ A connection is not a single JSON file: it is the credentials file plus
4
+ the auxiliary files (host.json, pending.json, runtime.lock, login.lock)
5
+ that live next to it. When the credentials file is named the canonical
6
+ ``credentials.json``, the auxiliary files keep their canonical names —
7
+ the same names llm-chatgpt-plan uses in its state directory — so pointing
8
+ this tool at an existing store shares files and locks with the old tool.
9
+ Any other file name gets auxiliary names derived by suffix, so distinct
10
+ stores never share auxiliary files.
11
+ """
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ from .errors import StorageError
17
+
18
+ ENV_VAR = "CHATGPT_AUTH_CREDENTIALS"
19
+ DEFAULT_DIR_NAME = "chatgpt-auth"
20
+ CREDENTIALS_NAME = "credentials.json"
21
+
22
+ CANONICAL_AUX = {
23
+ "host": "host.json",
24
+ "pending": "pending.json",
25
+ "runtime_lock": "runtime.lock",
26
+ "login_lock": "login.lock",
27
+ "models": "models.json",
28
+ }
29
+ AUX_SUFFIXES = {
30
+ "host": ".host.json",
31
+ "pending": ".pending.json",
32
+ "runtime_lock": ".runtime.lock",
33
+ "login_lock": ".login.lock",
34
+ "models": ".models.json",
35
+ }
36
+ RESERVED_NAMES = frozenset(CANONICAL_AUX.values())
37
+ RESERVED_SUFFIXES = tuple(AUX_SUFFIXES.values())
38
+
39
+
40
+ def validate_credentials_name(name: str) -> None:
41
+ """Reject file names that would collide with a store's own files."""
42
+ if not name or name in (".", ".."):
43
+ raise StorageError("Credentials file name is empty")
44
+ if name in RESERVED_NAMES or name.endswith(RESERVED_SUFFIXES):
45
+ raise StorageError(f"Reserved credentials file name: {name}")
46
+
47
+
48
+ def aux_path(credentials_path: Path, kind: str) -> Path:
49
+ """Auxiliary file for ``kind`` next to the given credentials file."""
50
+ if credentials_path.name == CREDENTIALS_NAME:
51
+ name = CANONICAL_AUX[kind]
52
+ else:
53
+ name = credentials_path.name + AUX_SUFFIXES[kind]
54
+ return credentials_path.parent / name
55
+
56
+
57
+ def _anchor(path: Path, cwd: Path) -> Path:
58
+ """Expand ~ and pin a relative path to the given working directory."""
59
+ expanded = Path(os.path.expanduser(str(path)))
60
+ if not expanded.is_absolute():
61
+ expanded = cwd / expanded
62
+ return Path(os.path.abspath(expanded))
63
+
64
+
65
+ def default_path(*, env=None, cwd=None) -> Path:
66
+ """${XDG_CONFIG_HOME:-~/.config}/chatgpt-auth/credentials.json.
67
+
68
+ A relative XDG_CONFIG_HOME is ignored in favor of ~/.config.
69
+ """
70
+ env = os.environ if env is None else env
71
+ cwd = Path.cwd() if cwd is None else cwd
72
+ xdg = env.get("XDG_CONFIG_HOME")
73
+ if xdg:
74
+ candidate = Path(os.path.expanduser(xdg))
75
+ base = candidate if candidate.is_absolute() else Path.home() / ".config"
76
+ else:
77
+ base = Path.home() / ".config"
78
+ return base / DEFAULT_DIR_NAME / CREDENTIALS_NAME
79
+
80
+
81
+ def resolve_path(explicit=None, *, env=None, cwd=None) -> Path:
82
+ """Resolve the credentials file path in priority order:
83
+
84
+ 1. the explicit ``path`` argument (CLI --credentials / API path)
85
+ 2. the CHATGPT_AUTH_CREDENTIALS environment variable
86
+ 3. the default location
87
+ """
88
+ env = os.environ if env is None else env
89
+ cwd = Path.cwd() if cwd is None else cwd
90
+ if explicit is not None:
91
+ path = _anchor(Path(explicit), cwd)
92
+ elif env.get(ENV_VAR):
93
+ path = _anchor(Path(env[ENV_VAR]), cwd)
94
+ else:
95
+ path = default_path(env=env, cwd=cwd)
96
+ validate_credentials_name(path.name)
97
+ return path