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/__init__.py +3 -0
- talis/api.py +470 -0
- talis/cli.py +70 -0
- talis/commands/__init__.py +1 -0
- talis/commands/_shared.py +152 -0
- talis/commands/auth.py +580 -0
- talis/commands/outcome.py +176 -0
- talis/commands/portfolio.py +153 -0
- talis/commands/snapshot.py +266 -0
- talis/commands/strategies.py +118 -0
- talis/commands/test_tenant.py +212 -0
- talis/commands/trade.py +168 -0
- talis/commands/wait.py +378 -0
- talis/config.py +160 -0
- talis/output.py +99 -0
- talis/paper.py +44 -0
- talis/symbols.py +63 -0
- talis_cli-0.1.0a1.dist-info/METADATA +99 -0
- talis_cli-0.1.0a1.dist-info/RECORD +22 -0
- talis_cli-0.1.0a1.dist-info/WHEEL +5 -0
- talis_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
- talis_cli-0.1.0a1.dist-info/top_level.txt +1 -0
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).")
|