ama-cli 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.
ama_cli/auth.py ADDED
@@ -0,0 +1,292 @@
1
+ """Browser loopback login for the public CLI.
2
+
3
+ OAuth 2.0 authorization code + PKCE over a loopback redirect (RFC 8252), the
4
+ same shape as `gh auth login`:
5
+
6
+ 1. bind 127.0.0.1:0 -- the OS picks a free port
7
+ 2. open the browser at {host}/api/v1/cli/auth?redirect_uri=...&state=...
8
+ &code_challenge=S256(verifier)
9
+ 3. the human signs in (WorkOS) and is redirected back to the listener
10
+ 4. check `state`, then POST {code, verifier} for an ama_ key
11
+
12
+ The key is never in a URL: the browser only carries a code, and the code is
13
+ useless without the verifier this process kept. URLs land in history, Referer
14
+ headers and proxy logs.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import http.server
22
+ import secrets
23
+ import socket
24
+ import threading
25
+ import urllib.parse
26
+ import webbrowser
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime
29
+
30
+ from ama_cli.credentials import Credentials
31
+
32
+ # Long enough for a real sign-in (including creating an account), short enough
33
+ # that a walked-away terminal does not hang forever.
34
+ LOGIN_TIMEOUT_SECONDS = 180
35
+ _VERIFIER_BYTES = 48 # -> 64 base64url chars, inside RFC 7636's 43..128
36
+
37
+
38
+ class LoginError(Exception):
39
+ """Login could not complete."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PkcePair:
44
+ """A PKCE verifier and its S256 challenge."""
45
+
46
+ verifier: str
47
+ challenge: str
48
+
49
+ @classmethod
50
+ def generate(cls) -> PkcePair:
51
+ verifier = secrets.token_urlsafe(_VERIFIER_BYTES)
52
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
53
+ challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
54
+ return cls(verifier=verifier, challenge=challenge)
55
+
56
+
57
+ @dataclass
58
+ class _Callback:
59
+ """What the browser handed back."""
60
+
61
+ code: str | None = None
62
+ state: str | None = None
63
+ error: str | None = None
64
+
65
+
66
+ _SUCCESS_HTML = """<!doctype html>
67
+ <meta charset="utf-8"><title>Ama CLI</title>
68
+ <style>
69
+ body{font-family:system-ui,-apple-system,sans-serif;display:grid;place-items:center;
70
+ height:100vh;margin:0;background:#0b0b0f;color:#e8e8ee}
71
+ .c{text-align:center}.t{font-size:1.5rem;font-weight:600;margin-bottom:.5rem}
72
+ .s{opacity:.6}
73
+ </style>
74
+ <div class="c"><div class="t">You're signed in</div>
75
+ <div class="s">Return to your terminal — you can close this tab.</div></div>
76
+ """
77
+
78
+ _FAILURE_HTML = """<!doctype html>
79
+ <meta charset="utf-8"><title>Ama CLI</title>
80
+ <style>
81
+ body{font-family:system-ui,-apple-system,sans-serif;display:grid;place-items:center;
82
+ height:100vh;margin:0;background:#0b0b0f;color:#e8e8ee}
83
+ .c{text-align:center}.t{font-size:1.5rem;font-weight:600;margin-bottom:.5rem}
84
+ .s{opacity:.6}
85
+ </style>
86
+ <div class="c"><div class="t">Sign-in failed</div>
87
+ <div class="s">Return to your terminal for details.</div></div>
88
+ """
89
+
90
+
91
+ class LoopbackListener:
92
+ """A one-shot HTTP server on 127.0.0.1 that catches the OAuth redirect.
93
+
94
+ Binds the loopback interface specifically, never 0.0.0.0 -- the latter would
95
+ expose the listener, and the authorization code, to the whole local network.
96
+ """
97
+
98
+ def __init__(self) -> None:
99
+ self._result = _Callback()
100
+ self._received = threading.Event()
101
+ self._server = self._build_server()
102
+
103
+ def _build_server(self) -> http.server.HTTPServer:
104
+ outer = self
105
+
106
+ class Handler(http.server.BaseHTTPRequestHandler):
107
+ def do_GET(self) -> None: # noqa: N802 - stdlib naming
108
+ parsed = urllib.parse.urlparse(self.path)
109
+ params = urllib.parse.parse_qs(parsed.query)
110
+ outer._result = _Callback(
111
+ code=_first(params, "code"),
112
+ state=_first(params, "state"),
113
+ error=_first(params, "error"),
114
+ )
115
+ ok = outer._result.code is not None and outer._result.error is None
116
+ body = (_SUCCESS_HTML if ok else _FAILURE_HTML).encode("utf-8")
117
+ self.send_response(200)
118
+ self.send_header("Content-Type", "text/html; charset=utf-8")
119
+ self.send_header("Content-Length", str(len(body)))
120
+ self.end_headers()
121
+ self.wfile.write(body)
122
+ outer._received.set()
123
+
124
+ def log_message(self, *args) -> None:
125
+ """Silence the stdlib access log; it would corrupt CLI output."""
126
+
127
+ # Port 0: the OS picks a free one. Nothing is pre-registered, so there is
128
+ # no fixed port to collide with another CLI or a local dev server.
129
+ return http.server.HTTPServer(("127.0.0.1", 0), Handler)
130
+
131
+ @property
132
+ def port(self) -> int:
133
+ return self._server.server_port
134
+
135
+ @property
136
+ def redirect_uri(self) -> str:
137
+ return f"http://127.0.0.1:{self.port}/cb"
138
+
139
+ def __enter__(self) -> LoopbackListener:
140
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
141
+ self._thread.start()
142
+ return self
143
+
144
+ def __exit__(self, *exc) -> None:
145
+ self._server.shutdown()
146
+ self._server.server_close()
147
+
148
+ def wait(self, timeout: float = LOGIN_TIMEOUT_SECONDS) -> _Callback:
149
+ """Block until the browser calls back, or time out.
150
+
151
+ Raises:
152
+ LoginError: on timeout, so the caller can fall back rather than hang.
153
+ """
154
+ if not self._received.wait(timeout):
155
+ raise LoginError(
156
+ f"timed out after {int(timeout)}s waiting for the browser. "
157
+ "Use --no-browser to sign in by email instead."
158
+ )
159
+ return self._result
160
+
161
+
162
+ def _first(params: dict[str, list[str]], key: str) -> str | None:
163
+ values = params.get(key)
164
+ return values[0] if values else None
165
+
166
+
167
+ def authorize_url(
168
+ host: str, *, redirect_uri: str, state: str, challenge: str, client_name: str
169
+ ) -> str:
170
+ """The browser URL that starts the flow."""
171
+ query = urllib.parse.urlencode(
172
+ {
173
+ "redirect_uri": redirect_uri,
174
+ "state": state,
175
+ "code_challenge": challenge,
176
+ "code_challenge_method": "S256",
177
+ "client_name": client_name,
178
+ }
179
+ )
180
+ return f"{host.rstrip('/')}/api/v1/cli/auth?{query}"
181
+
182
+
183
+ def default_client_name() -> str:
184
+ """A key name the user will recognise in settings: the machine it lives on."""
185
+ try:
186
+ hostname = socket.gethostname().split(".")[0]
187
+ except OSError:
188
+ hostname = "unknown"
189
+ return f"ama-cli@{hostname}"
190
+
191
+
192
+ def login(
193
+ host: str,
194
+ *,
195
+ open_browser=webbrowser.open,
196
+ post_token=None,
197
+ timeout: float = LOGIN_TIMEOUT_SECONDS,
198
+ on_url=None,
199
+ ) -> Credentials:
200
+ """Run the loopback flow and return credentials.
201
+
202
+ `open_browser` and `post_token` are injected so tests can drive the whole
203
+ flow without a browser or a network.
204
+
205
+ Raises:
206
+ LoginError: on timeout, state mismatch, or a rejected exchange.
207
+ """
208
+ if post_token is None:
209
+ post_token = _post_token
210
+
211
+ pkce = PkcePair.generate()
212
+ state = secrets.token_urlsafe(16)
213
+ client_name = default_client_name()
214
+
215
+ with LoopbackListener() as listener:
216
+ url = authorize_url(
217
+ host,
218
+ redirect_uri=listener.redirect_uri,
219
+ state=state,
220
+ challenge=pkce.challenge,
221
+ client_name=client_name,
222
+ )
223
+ if on_url:
224
+ on_url(url)
225
+ open_browser(url)
226
+
227
+ callback = listener.wait(timeout)
228
+
229
+ if callback.error:
230
+ raise LoginError(f"authorization failed: {callback.error}")
231
+ if callback.code is None:
232
+ raise LoginError("no authorization code was returned")
233
+ # Reject a callback from a different session. Compared with compare_digest
234
+ # rather than == so the check is not a timing oracle.
235
+ if callback.state is None or not secrets.compare_digest(callback.state, state):
236
+ raise LoginError("state mismatch — ignoring a callback this login did not start")
237
+
238
+ payload = post_token(
239
+ f"{host.rstrip('/')}/api/v1/cli/auth/token",
240
+ {
241
+ "code": callback.code,
242
+ "code_verifier": pkce.verifier,
243
+ "redirect_uri": f"http://127.0.0.1:{_port_of(listener.redirect_uri)}/cb",
244
+ },
245
+ )
246
+
247
+ return Credentials(
248
+ host=payload.get("host", host),
249
+ api_key=payload["api_key"],
250
+ mcp_url=payload["mcp_url"],
251
+ email=payload.get("email"),
252
+ tenant_id=payload.get("tenant_id"),
253
+ created_at=datetime.now(UTC).isoformat(),
254
+ )
255
+
256
+
257
+ def _port_of(redirect_uri: str) -> int:
258
+ return urllib.parse.urlparse(redirect_uri).port or 0
259
+
260
+
261
+ def _post_token(url: str, body: dict) -> dict:
262
+ """Exchange the code for a key.
263
+
264
+ urllib rather than httpx: this is the only HTTP the CLI makes, and one
265
+ stdlib call is cheaper than a dependency on the install path of a one-line
266
+ onboarding command.
267
+ """
268
+ import json
269
+ import urllib.error
270
+ import urllib.request
271
+
272
+ data = json.dumps(body).encode()
273
+ req = urllib.request.Request(
274
+ url, data=data, headers={"Content-Type": "application/json"}, method="POST"
275
+ )
276
+ try:
277
+ with urllib.request.urlopen(req, timeout=30) as resp:
278
+ return json.loads(resp.read())
279
+ except urllib.error.HTTPError as exc:
280
+ detail = _detail(exc)
281
+ raise LoginError(f"token exchange failed ({exc.code}): {detail}") from exc
282
+ except urllib.error.URLError as exc:
283
+ raise LoginError(f"could not reach {url}: {exc.reason}") from exc
284
+
285
+
286
+ def _detail(exc) -> str:
287
+ import json
288
+
289
+ try:
290
+ return json.loads(exc.read()).get("detail", "unknown error")
291
+ except Exception:
292
+ return "unknown error"