talis-cli 0.1.0a1__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.
talis/commands/auth.py ADDED
@@ -0,0 +1,580 @@
1
+ """
2
+ Authentication commands: login, logout, whoami, sessions, sessions revoke.
3
+
4
+ ``talis login`` is the most novel command — it drives the device flow:
5
+
6
+ 1. POST /auth/device/code — returns user_code + verification_uri + words
7
+ 2. Open the URL in the user's browser; print the words for verification
8
+ 3. Poll POST /auth/device/token until status="approved"
9
+ 4. Persist the returned JWT to ~/.talis/credentials
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import contextlib
16
+ import hashlib
17
+ import http.server
18
+ import platform
19
+ import secrets
20
+ import time
21
+ import urllib.parse
22
+ import webbrowser
23
+
24
+ import typer
25
+
26
+ from .. import api, config, output
27
+ from ._shared import creds_from_jwt, jti_from_jwt, require_creds
28
+
29
+
30
+ class _LoopbackUnavailable(Exception):
31
+ """Raised when the loopback flow can't run (can't bind, no browser) so the
32
+ caller falls back to the device-code flow."""
33
+
34
+ # `sessions_app` is a real subgroup mounted at "talis sessions" by cli.py.
35
+ # `login`/`logout`/`whoami` below are plain functions — cli.py registers them
36
+ # directly on the root app to avoid reaching into Typer internals.
37
+ sessions_app = typer.Typer(help="List and revoke active CLI/web/mobile sessions.")
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # login
42
+ # --------------------------------------------------------------------------- #
43
+
44
+
45
+ def login(
46
+ read_only: bool = typer.Option(
47
+ False, "--read-only",
48
+ help="Request a read-scope session — denied on every trade/withdraw endpoint.",
49
+ ),
50
+ client_name: str | None = typer.Option(
51
+ None, "--client-name",
52
+ help="Display name shown on the approval page. Defaults to hostname.",
53
+ ),
54
+ no_browser: bool = typer.Option(
55
+ False, "--no-browser",
56
+ help="Don't try to open the browser automatically.",
57
+ ),
58
+ device_code: bool = typer.Option(
59
+ False, "--device-code",
60
+ help=(
61
+ "Use the type-a-code device flow instead of the default browser "
62
+ "loopback login. For SSH / headless boxes where the browser can't "
63
+ "redirect back to this machine."
64
+ ),
65
+ ),
66
+ token: str | None = typer.Option(
67
+ None, "--token",
68
+ help=(
69
+ "Bypass the device flow with an existing Talis JWT. Intended for "
70
+ "non-interactive setups (CI, agents). The token is decoded "
71
+ "locally to populate the credentials file; the server still "
72
+ "validates the signature on every request."
73
+ ),
74
+ envvar="TALIS_TOKEN", # Distinct from JARVIS_TOKEN (which is ephemeral, never persisted)
75
+ ),
76
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
77
+ ):
78
+ """Sign in to Talis. Either via device flow (default) or via ``--token`` for non-interactive setups."""
79
+ scope = "read" if read_only else "trade"
80
+ name = client_name or _default_client_name()
81
+ endpoint = config.endpoint_from_env()
82
+
83
+ # Non-interactive path: caller already has a JWT (typically from
84
+ # ``POST /auth/tokens`` via an admin key, or another machine's
85
+ # ``talis login`` output piped over a secret channel). Save the
86
+ # bundle and exit — no device flow, no polling, no browser.
87
+ if token is not None:
88
+ token_str = token.strip()
89
+ if not token_str:
90
+ output.error("--token cannot be empty.")
91
+ raise typer.Exit(code=1)
92
+ creds = creds_from_jwt(token_str, endpoint=endpoint, client_name=name)
93
+ config.save(creds)
94
+ if output.should_render_json(json_):
95
+ output.emit_json({
96
+ "ok": True,
97
+ "tenant_id": creds.tenant_id,
98
+ "scope": creds.scope,
99
+ "expires_at": creds.expires_at,
100
+ "via": "token",
101
+ })
102
+ else:
103
+ output.info("[green]signed in (token).[/]")
104
+ output.render_kv(
105
+ [
106
+ ("tenant", creds.tenant_id),
107
+ ("scope", creds.scope),
108
+ ("wallet", creds.wallet_address or "—"),
109
+ ("expires", creds.expires_at or "—"),
110
+ ],
111
+ json_flag=False,
112
+ )
113
+ return
114
+
115
+ # Default: codex-style loopback login (the browser redirects straight back —
116
+ # no code typing). Fall back to the device-code flow for headless/SSH boxes
117
+ # or if the loopback listener can't run.
118
+ if device_code or no_browser:
119
+ return _device_flow_login(scope, name, endpoint, no_browser=no_browser, json_=json_)
120
+ try:
121
+ return _loopback_login(scope, name, endpoint, json_=json_)
122
+ except _LoopbackUnavailable as exc:
123
+ output.warn(f"browser login unavailable ({exc}); falling back to device-code flow.")
124
+ return _device_flow_login(scope, name, endpoint, no_browser=no_browser, json_=json_)
125
+
126
+
127
+ def _device_flow_login(
128
+ scope: str, name: str, endpoint: str, *, no_browser: bool, json_: bool
129
+ ) -> None:
130
+ """RFC 8628 device-code flow (type-a-code) — the SSH/headless fallback."""
131
+ with api.Client(endpoint=endpoint) as c:
132
+ try:
133
+ initial = c.device_code(client_name=name, scope=scope)
134
+ except api.APIError as exc:
135
+ output.error(f"could not start login: {exc.detail}")
136
+ raise typer.Exit(code=1)
137
+
138
+ device_code = initial["device_code"]
139
+ user_code = initial["user_code"]
140
+ verification_uri = initial["verification_uri"]
141
+ verification_uri_complete = initial.get("verification_uri_complete", verification_uri)
142
+ words = initial.get("verification_words", [])
143
+ interval = max(1, int(initial.get("interval", 5)))
144
+ expires_in = int(initial.get("expires_in", 600))
145
+
146
+ # Show the user what to do. Always print to stderr so --json keeps
147
+ # stdout clean for the final token bundle.
148
+ output.banner([
149
+ "",
150
+ f"[bold]Open this URL[/]: [link={verification_uri}]{verification_uri}[/link]",
151
+ f"[bold]Enter this code[/]: [cyan]{user_code}[/cyan]",
152
+ "",
153
+ "[bold]Verification words[/] (these must match what the web page shows):",
154
+ f" [magenta]{' · '.join(words)}[/magenta]" if words else "",
155
+ "",
156
+ f"[dim]Waiting for approval … (expires in {expires_in // 60}m {expires_in % 60}s)[/]",
157
+ "",
158
+ ])
159
+
160
+ if not no_browser:
161
+ import contextlib
162
+ with contextlib.suppress(Exception):
163
+ webbrowser.open(verification_uri_complete)
164
+ # printed URL above is the fallback if open() failed
165
+
166
+ # Poll. We follow the server's interval hint AND back off on slow_down.
167
+ deadline = time.time() + expires_in
168
+ while time.time() < deadline:
169
+ try:
170
+ resp = c.device_token(device_code=device_code)
171
+ except api.APIError as exc:
172
+ output.error(f"polling failed: {exc.detail}")
173
+ raise typer.Exit(code=1)
174
+
175
+ status = resp.get("status")
176
+ if status == "approved":
177
+ # Extract jti from the JWT payload (unsigned decode is safe — we
178
+ # only inspect our own token; the server validates on receipt).
179
+ # Stored so `talis logout` can self-revoke without an extra
180
+ # /auth/sessions round trip.
181
+ jti = jti_from_jwt(resp["token"])
182
+ creds = config.Credentials(
183
+ endpoint=endpoint,
184
+ tenant_id=resp["tenant_id"],
185
+ token=resp["token"],
186
+ expires_at=resp.get("expires_at", ""),
187
+ scope=scope,
188
+ jti_prefix=jti[:8] if jti else "",
189
+ wallet_address=resp.get("wallet_address"),
190
+ wallet_id=resp.get("wallet_id"),
191
+ client_name=name,
192
+ )
193
+ config.save(creds)
194
+ output.info("[green]signed in.[/]")
195
+ if output.should_render_json(json_):
196
+ output.emit_json({
197
+ "ok": True,
198
+ "tenant_id": creds.tenant_id,
199
+ "scope": creds.scope,
200
+ "expires_at": creds.expires_at,
201
+ })
202
+ else:
203
+ output.render_kv(
204
+ [
205
+ ("tenant", creds.tenant_id),
206
+ ("scope", creds.scope),
207
+ ("wallet", creds.wallet_address or "—"),
208
+ ("expires", creds.expires_at or "—"),
209
+ ],
210
+ json_flag=False,
211
+ )
212
+ return
213
+
214
+ if status == "slow_down":
215
+ server_interval = int(resp.get("interval", interval + 5))
216
+ interval = max(interval + 5, server_interval)
217
+ time.sleep(interval)
218
+ continue
219
+
220
+ if status == "expired":
221
+ output.error("code expired before approval. Run `talis login` again.")
222
+ raise typer.Exit(code=1)
223
+
224
+ if status == "denied":
225
+ output.error("approval was denied.")
226
+ raise typer.Exit(code=1)
227
+
228
+ # status == "pending" or unknown → keep polling at the advertised interval
229
+ time.sleep(interval)
230
+
231
+ output.error("login timed out.")
232
+ raise typer.Exit(code=1)
233
+
234
+
235
+ def _loopback_login(scope: str, name: str, endpoint: str, *, json_: bool) -> None:
236
+ """Codex-style loopback login (RFC 8252 + PKCE).
237
+
238
+ Open the browser to the auth page; after the user signs in (Privy) and
239
+ authorizes, the page redirects straight back to a ``http://127.0.0.1:<port>``
240
+ listener this CLI started — no code typing. We exchange the captured code
241
+ (proving possession with the PKCE verifier) for the Talis JWT.
242
+ """
243
+ # PKCE (RFC 7636): high-entropy verifier; S256 challenge.
244
+ verifier = secrets.token_urlsafe(48) # ~64 chars, inside the 43–128 window
245
+ challenge = (
246
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest())
247
+ .rstrip(b"=")
248
+ .decode("ascii")
249
+ )
250
+ state = secrets.token_urlsafe(24) # CSRF: echoed back, verified below
251
+ captured: dict[str, str | None] = {}
252
+
253
+ class _Handler(http.server.BaseHTTPRequestHandler):
254
+ def do_GET(self): # noqa: N802 — stdlib handler name
255
+ parsed = urllib.parse.urlparse(self.path)
256
+ if parsed.path != "/callback":
257
+ self.send_response(404)
258
+ self.end_headers()
259
+ return
260
+ q = urllib.parse.parse_qs(parsed.query)
261
+ captured["code"] = q.get("code", [None])[0]
262
+ captured["state"] = q.get("state", [None])[0]
263
+ captured["error"] = q.get("error", [None])[0]
264
+ ok = bool(captured["code"]) and not captured["error"]
265
+ self.send_response(200)
266
+ self.send_header("Content-Type", "text/html; charset=utf-8")
267
+ self.end_headers()
268
+ head = "✓ Signed in to Talis" if ok else "Login failed"
269
+ sub = (
270
+ "You can close this tab and return to your terminal."
271
+ if ok else "Return to your terminal and try again."
272
+ )
273
+ self.wfile.write(
274
+ (
275
+ "<!doctype html><html><body style='font-family:-apple-system,"
276
+ "system-ui,sans-serif;text-align:center;padding-top:80px;color:#111'>"
277
+ f"<h2>{head}</h2><p style='color:#666'>{sub}</p></body></html>"
278
+ ).encode()
279
+ )
280
+
281
+ def log_message(self, *args): # silence default stderr access logging
282
+ return
283
+
284
+ try:
285
+ server = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
286
+ except OSError as exc:
287
+ raise _LoopbackUnavailable(f"cannot bind loopback listener: {exc}")
288
+ port = server.server_address[1]
289
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
290
+
291
+ auth_url = config.auth_page_from_env(endpoint)
292
+ open_url = f"{auth_url}?" + urllib.parse.urlencode({
293
+ "response_type": "code",
294
+ "code_challenge": challenge,
295
+ "code_challenge_method": "S256",
296
+ "redirect_uri": redirect_uri,
297
+ "scope": scope,
298
+ "state": state,
299
+ "client_name": name,
300
+ })
301
+
302
+ output.banner([
303
+ "",
304
+ "[bold]Opening your browser to sign in…[/]",
305
+ f"[dim]If it doesn't open, visit:[/] [link={open_url}]{open_url}[/link]",
306
+ "",
307
+ "[dim]Waiting for you to approve in the browser…[/]",
308
+ "",
309
+ ])
310
+ with contextlib.suppress(Exception):
311
+ webbrowser.open(open_url)
312
+
313
+ # Block on the single callback (or time out). handle_request() returns each
314
+ # second so the deadline is honored even if the browser never comes back.
315
+ server.timeout = 1.0
316
+ deadline = time.time() + 300
317
+ while time.time() < deadline and not captured.get("code") and not captured.get("error"):
318
+ server.handle_request()
319
+ with contextlib.suppress(Exception):
320
+ server.server_close()
321
+
322
+ if captured.get("error"):
323
+ output.error(f"login was denied or failed: {captured['error']}")
324
+ raise typer.Exit(code=1)
325
+ code = captured.get("code")
326
+ if not code:
327
+ output.error("login timed out waiting for browser approval.")
328
+ raise typer.Exit(code=1)
329
+ if captured.get("state") != state:
330
+ # The code came back bound to a state we didn't issue — refuse it.
331
+ output.error("state mismatch — possible CSRF; aborting login.")
332
+ raise typer.Exit(code=1)
333
+
334
+ with api.Client(endpoint=endpoint) as c:
335
+ try:
336
+ resp = c.oauth_token(code=code, code_verifier=verifier, redirect_uri=redirect_uri)
337
+ except api.APIError as exc:
338
+ output.error(f"token exchange failed: {exc.detail}")
339
+ raise typer.Exit(code=1)
340
+
341
+ jti = jti_from_jwt(resp["access_token"])
342
+ creds = config.Credentials(
343
+ endpoint=endpoint,
344
+ tenant_id=resp["tenant_id"],
345
+ token=resp["access_token"],
346
+ expires_at=resp.get("expires_at", ""),
347
+ scope=resp.get("scope", scope),
348
+ jti_prefix=jti[:8] if jti else "",
349
+ wallet_address=resp.get("wallet_address"),
350
+ wallet_id=resp.get("wallet_id"),
351
+ client_name=name,
352
+ )
353
+ config.save(creds)
354
+ output.info("[green]signed in.[/]")
355
+ if output.should_render_json(json_):
356
+ output.emit_json({
357
+ "ok": True,
358
+ "tenant_id": creds.tenant_id,
359
+ "scope": creds.scope,
360
+ "expires_at": creds.expires_at,
361
+ "via": "loopback",
362
+ })
363
+ else:
364
+ output.render_kv(
365
+ [
366
+ ("tenant", creds.tenant_id),
367
+ ("scope", creds.scope),
368
+ ("wallet", creds.wallet_address or "—"),
369
+ ("expires", creds.expires_at or "—"),
370
+ ],
371
+ json_flag=False,
372
+ )
373
+
374
+
375
+ def approve(
376
+ user_code: str = typer.Argument(
377
+ ...,
378
+ help="The code printed by `talis login`, e.g. ABCD-EFGH.",
379
+ ),
380
+ read_only: bool = typer.Option(
381
+ False, "--read-only",
382
+ help="Downgrade the approved session to read scope.",
383
+ ),
384
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
385
+ ):
386
+ """Approve a pending CLI login using this machine's current Talis session.
387
+
388
+ This is the headless sibling of the web/iOS approval screen: it lets a
389
+ signed-in CLI approve another `talis login` code without requiring a
390
+ browser. The server still verifies the current JWT and refuses scope
391
+ escalation.
392
+ """
393
+ creds = require_creds()
394
+ scope = "read" if read_only else None
395
+ try:
396
+ with api.client_from_credentials(creds) as c:
397
+ result = c.device_approve(user_code=user_code, scope=scope)
398
+ except api.APIError as exc:
399
+ output.error(f"approval failed: {exc.detail}")
400
+ raise typer.Exit(code=1)
401
+
402
+ if output.should_render_json(json_):
403
+ output.emit_json(result)
404
+ else:
405
+ output.info("[green]approved.[/]")
406
+ output.render_kv(
407
+ [
408
+ ("client", result.get("client_name", "—")),
409
+ ("scope", result.get("scope_granted", "—")),
410
+ ],
411
+ json_flag=False,
412
+ )
413
+
414
+
415
+ def _default_client_name() -> str:
416
+ """Build a sensible default like "Talis CLI on MacBook-Air"."""
417
+ host = platform.node() or "this machine"
418
+ return f"Talis CLI on {host}"
419
+
420
+
421
+ # --------------------------------------------------------------------------- #
422
+ # logout
423
+ # --------------------------------------------------------------------------- #
424
+
425
+
426
+ def logout(
427
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
428
+ ):
429
+ """Revoke the current session and clear local credentials.
430
+
431
+ The point of logout is to invalidate THIS JWT server-side — otherwise a
432
+ leaked token stays valid until expires_at even after the user runs
433
+ ``talis logout``. We call DELETE /auth/sessions/{jti_prefix} (self-revoke),
434
+ NOT DELETE /auth/sessions (which preserves the current session and would
435
+ leave the token usable).
436
+ """
437
+ creds = config.load()
438
+ if not creds:
439
+ output.info("no credentials on disk; nothing to do.")
440
+ if output.should_render_json(json_):
441
+ output.emit_json({"ok": True, "revoked": False})
442
+ return
443
+
444
+ # Identify the current session. Prefer the stored prefix; fall back to
445
+ # decoding the JWT (handles credentials saved by older CLI builds that
446
+ # didn't populate jti_prefix). If we can't identify it, refuse to silently
447
+ # leave the server-side session live — surface the gap so the user knows.
448
+ jti_prefix = creds.jti_prefix or jti_from_jwt(creds.token)[:8]
449
+
450
+ revoked = False
451
+ if jti_prefix:
452
+ try:
453
+ with api.client_from_credentials(creds) as c:
454
+ c.revoke_session(tenant_id=creds.tenant_id, jti_prefix=jti_prefix)
455
+ revoked = True
456
+ except api.APIError as exc:
457
+ output.warn(
458
+ f"server-side revoke returned {exc.status_code}: {exc.detail}. "
459
+ "Local credentials cleared; the session may remain valid server-side "
460
+ "until expires_at."
461
+ )
462
+ except Exception as exc: # noqa: BLE001
463
+ output.warn(
464
+ f"server unreachable ({exc}). Local credentials cleared; the session "
465
+ "remains valid server-side until expires_at."
466
+ )
467
+ else:
468
+ output.warn(
469
+ "could not identify current session jti — local credentials cleared, "
470
+ "but the server-side session remains valid until expires_at. "
471
+ "Use `talis sessions list` from another device to revoke it."
472
+ )
473
+
474
+ config.clear()
475
+ if output.should_render_json(json_):
476
+ output.emit_json({"ok": True, "revoked": revoked})
477
+ else:
478
+ output.info("signed out.")
479
+
480
+
481
+ # --------------------------------------------------------------------------- #
482
+ # whoami
483
+ # --------------------------------------------------------------------------- #
484
+
485
+
486
+ def whoami(
487
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
488
+ ):
489
+ """Show the active session: tenant, scope, expiry."""
490
+ # Goes through require_creds so JARVIS_TOKEN takes precedence — a CI
491
+ # runner exporting an admin-minted token can verify which tenant it
492
+ # was issued for without needing a saved credentials file.
493
+ creds = require_creds()
494
+
495
+ rows = [
496
+ ("tenant", creds.tenant_id),
497
+ ("scope", creds.scope),
498
+ ("endpoint", creds.endpoint),
499
+ ("wallet", creds.wallet_address or "—"),
500
+ ("expires", creds.expires_at or "—"),
501
+ ]
502
+ output.render_kv(rows, json_flag=json_)
503
+
504
+
505
+ # --------------------------------------------------------------------------- #
506
+ # sessions
507
+ # --------------------------------------------------------------------------- #
508
+
509
+
510
+ @sessions_app.command("list")
511
+ def sessions_list(
512
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
513
+ ):
514
+ """List active sessions for your account (Connected Devices)."""
515
+ creds = require_creds()
516
+ with api.client_from_credentials(creds) as c:
517
+ try:
518
+ resp = c.list_sessions(tenant_id=creds.tenant_id)
519
+ except api.APIError as exc:
520
+ output.error(f"could not list sessions: {exc.detail}")
521
+ raise typer.Exit(code=1)
522
+
523
+ sessions = resp.get("sessions", [])
524
+ if output.should_render_json(json_):
525
+ output.emit_json({"sessions": sessions})
526
+ return
527
+
528
+ rows = []
529
+ for s in sessions:
530
+ marker = "[green](this)[/]" if s.get("is_current") else ""
531
+ rows.append([
532
+ s.get("jti_prefix", ""),
533
+ s.get("session_kind", ""),
534
+ s.get("scope", ""),
535
+ s.get("client_name", ""),
536
+ s.get("last_used_ip", "") or "—",
537
+ marker,
538
+ ])
539
+ output.render_table(
540
+ ["jti", "kind", "scope", "client", "last IP", ""],
541
+ rows,
542
+ json_flag=False,
543
+ )
544
+
545
+
546
+ @sessions_app.command("revoke")
547
+ def sessions_revoke(
548
+ jti_prefix: str = typer.Argument(..., help="Short jti prefix (e.g. ab12cd34)."),
549
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
550
+ ):
551
+ """Revoke one session by short prefix."""
552
+ creds = require_creds()
553
+ with api.client_from_credentials(creds) as c:
554
+ try:
555
+ resp = c.revoke_session(tenant_id=creds.tenant_id, jti_prefix=jti_prefix)
556
+ except api.APIError as exc:
557
+ output.error(f"revoke failed: {exc.detail}")
558
+ raise typer.Exit(code=1)
559
+ if output.should_render_json(json_):
560
+ output.emit_json(resp)
561
+ else:
562
+ output.info("session revoked.")
563
+
564
+
565
+ @sessions_app.command("revoke-others")
566
+ def sessions_revoke_others(
567
+ json_: bool = typer.Option(False, "--json", help="JSON output."),
568
+ ):
569
+ """Sign out from every other device. The current session is preserved."""
570
+ creds = require_creds()
571
+ with api.client_from_credentials(creds) as c:
572
+ try:
573
+ resp = c.revoke_other_sessions(tenant_id=creds.tenant_id)
574
+ except api.APIError as exc:
575
+ output.error(f"revoke failed: {exc.detail}")
576
+ raise typer.Exit(code=1)
577
+ if output.should_render_json(json_):
578
+ output.emit_json(resp)
579
+ else:
580
+ output.info(f"revoked {resp.get('revoked', 0)} other session(s).")