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.
- chatgpt_auth/__init__.py +36 -0
- chatgpt_auth/__main__.py +4 -0
- chatgpt_auth/callback.py +196 -0
- chatgpt_auth/cli.py +247 -0
- chatgpt_auth/config.py +97 -0
- chatgpt_auth/credentials.py +145 -0
- chatgpt_auth/errors.py +108 -0
- chatgpt_auth/oauth.py +358 -0
- chatgpt_auth/service.py +432 -0
- chatgpt_auth/storage.py +414 -0
- chatgpt_auth/types.py +55 -0
- chatgpt_auth-0.1.0.dist-info/METADATA +177 -0
- chatgpt_auth-0.1.0.dist-info/RECORD +17 -0
- chatgpt_auth-0.1.0.dist-info/WHEEL +5 -0
- chatgpt_auth-0.1.0.dist-info/entry_points.txt +2 -0
- chatgpt_auth-0.1.0.dist-info/licenses/LICENSE +201 -0
- chatgpt_auth-0.1.0.dist-info/top_level.txt +1 -0
chatgpt_auth/__init__.py
ADDED
|
@@ -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
|
+
]
|
chatgpt_auth/__main__.py
ADDED
chatgpt_auth/callback.py
ADDED
|
@@ -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
|