delos-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.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
delos_cli/auth/mfa.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Supabase MFA challenge for the CLI login flow.
|
|
2
|
+
|
|
3
|
+
Compagnon's equivalent lives at ``apps/compagnon/src/hooks/use-auth.tsx`` — it
|
|
4
|
+
calls ``supabase.auth.mfa.getAuthenticatorAssuranceLevel()`` then
|
|
5
|
+
``mfa.challengeAndVerify({factorId, code})`` to upgrade an ``aal1`` session
|
|
6
|
+
(freshly minted by ``/api/oauth/token``'s ``getSessionTokens`` magic-link path)
|
|
7
|
+
to ``aal2``. We replicate that directly against the Supabase auth REST API so
|
|
8
|
+
we don't pull in ``supabase-py`` just for these three calls.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
HTTP_ERROR_THRESHOLD = 400
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MfaError(RuntimeError):
|
|
24
|
+
"""Raised when the MFA upgrade fails (list, challenge, or verify)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MfaFactor:
|
|
29
|
+
"""A verified TOTP factor the user can challenge against."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
friendly_name: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class UpgradedSession:
|
|
37
|
+
"""The aal2 session Supabase returns after a successful verify."""
|
|
38
|
+
|
|
39
|
+
access_token: str
|
|
40
|
+
refresh_token: str
|
|
41
|
+
expires_in: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def decode_aal(access_token: str) -> str:
|
|
45
|
+
"""Extract the ``aal`` claim from a Supabase JWT. Defaults to ``aal1``."""
|
|
46
|
+
parts = access_token.split(".")
|
|
47
|
+
if len(parts) < 2: # noqa: PLR2004 — JWT shape is fixed
|
|
48
|
+
return "aal1"
|
|
49
|
+
payload_b64 = parts[1]
|
|
50
|
+
padding = (-len(payload_b64)) % 4
|
|
51
|
+
payload_b64 += "=" * padding
|
|
52
|
+
try:
|
|
53
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
54
|
+
except (ValueError, json.JSONDecodeError):
|
|
55
|
+
return "aal1"
|
|
56
|
+
return str(payload.get("aal", "aal1"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def list_verified_factors(
|
|
60
|
+
supabase_url: str, anon_key: str, access_token: str,
|
|
61
|
+
) -> list[MfaFactor]:
|
|
62
|
+
"""Return verified TOTP factors for the current user.
|
|
63
|
+
|
|
64
|
+
Supabase's JS ``mfa.listFactors()`` reads from ``GET /auth/v1/user`` and
|
|
65
|
+
extracts the ``factors`` array — there's no dedicated list endpoint for
|
|
66
|
+
regular users (``GET /auth/v1/factors`` is 405; it's POST-only for enroll).
|
|
67
|
+
"""
|
|
68
|
+
headers = {"apikey": anon_key, "Authorization": f"Bearer {access_token}"}
|
|
69
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
70
|
+
resp = await http.get(f"{supabase_url}/auth/v1/user", headers=headers)
|
|
71
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
72
|
+
msg = f"fetch user failed: HTTP {resp.status_code}: {resp.text[:200]}"
|
|
73
|
+
raise MfaError(msg)
|
|
74
|
+
user = resp.json()
|
|
75
|
+
factors_raw: list[dict[str, Any]] = user.get("factors") or []
|
|
76
|
+
return [
|
|
77
|
+
MfaFactor(id=str(f["id"]), friendly_name=str(f.get("friendly_name") or "TOTP"))
|
|
78
|
+
for f in factors_raw
|
|
79
|
+
if f.get("status") == "verified" and f.get("factor_type") == "totp"
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def challenge_and_verify(
|
|
84
|
+
supabase_url: str,
|
|
85
|
+
anon_key: str,
|
|
86
|
+
access_token: str,
|
|
87
|
+
factor_id: str,
|
|
88
|
+
code: str,
|
|
89
|
+
) -> UpgradedSession:
|
|
90
|
+
"""Run Supabase's challenge + verify flow, returning fresh aal2 tokens."""
|
|
91
|
+
headers = {
|
|
92
|
+
"apikey": anon_key,
|
|
93
|
+
"Authorization": f"Bearer {access_token}",
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
}
|
|
96
|
+
base = f"{supabase_url}/auth/v1/factors/{factor_id}"
|
|
97
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
98
|
+
chal = await http.post(f"{base}/challenge", headers=headers)
|
|
99
|
+
if chal.status_code >= HTTP_ERROR_THRESHOLD:
|
|
100
|
+
msg = f"challenge failed: HTTP {chal.status_code}: {chal.text[:200]}"
|
|
101
|
+
raise MfaError(msg)
|
|
102
|
+
challenge_id = chal.json().get("id")
|
|
103
|
+
if not challenge_id:
|
|
104
|
+
msg = f"challenge response missing id: {chal.text[:200]}"
|
|
105
|
+
raise MfaError(msg)
|
|
106
|
+
|
|
107
|
+
verify = await http.post(
|
|
108
|
+
f"{base}/verify",
|
|
109
|
+
headers=headers,
|
|
110
|
+
json={"challenge_id": challenge_id, "code": code},
|
|
111
|
+
)
|
|
112
|
+
if verify.status_code >= HTTP_ERROR_THRESHOLD:
|
|
113
|
+
msg = f"verify failed: HTTP {verify.status_code}: {verify.text[:200]}"
|
|
114
|
+
raise MfaError(msg)
|
|
115
|
+
data = verify.json()
|
|
116
|
+
return UpgradedSession(
|
|
117
|
+
access_token=str(data["access_token"]),
|
|
118
|
+
refresh_token=str(data["refresh_token"]),
|
|
119
|
+
expires_in=int(data.get("expires_in", 3600)),
|
|
120
|
+
)
|
delos_cli/auth/oauth.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""OAuth PKCE loopback flow for `delos login`.
|
|
2
|
+
|
|
3
|
+
Reuses the same `/authorize` + `/api/oauth/token` endpoints that the compagnon
|
|
4
|
+
browser extension uses. Steps:
|
|
5
|
+
|
|
6
|
+
1. Register a `Delos CLI` OAuth client via RFC 7591 dynamic registration
|
|
7
|
+
(cached per-config so subsequent logins skip this step).
|
|
8
|
+
2. Generate a PKCE S256 verifier/challenge pair.
|
|
9
|
+
3. Spin up a one-shot HTTP server on 127.0.0.1:<free_port>/callback.
|
|
10
|
+
4. Open the browser to {web}/authorize with offline_access scope.
|
|
11
|
+
5. Catch the redirect, exchange the code at /api/oauth/token, persist tokens.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import base64
|
|
18
|
+
import contextlib
|
|
19
|
+
import hashlib
|
|
20
|
+
import http.server
|
|
21
|
+
import json
|
|
22
|
+
import secrets
|
|
23
|
+
import socket
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import webbrowser
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
29
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from .config import Config, Tokens
|
|
34
|
+
from .mfa import (
|
|
35
|
+
MfaError,
|
|
36
|
+
challenge_and_verify,
|
|
37
|
+
decode_aal,
|
|
38
|
+
list_verified_factors,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Awaitable, Callable
|
|
43
|
+
|
|
44
|
+
from .mfa import MfaFactor
|
|
45
|
+
|
|
46
|
+
REDIRECT_PATH = "/callback"
|
|
47
|
+
CALLBACK_TIMEOUT_SECONDS = 600
|
|
48
|
+
DEFAULT_EXPIRES_IN = 3600
|
|
49
|
+
HTTP_ERROR_THRESHOLD = 400
|
|
50
|
+
|
|
51
|
+
# Canonical CLI client, pre-registered in oauth_clients.
|
|
52
|
+
# Must match the row inserted by the infra SQL migration.
|
|
53
|
+
CLIENT_ID = "delos-cli"
|
|
54
|
+
LOOPBACK_PORT = 52700
|
|
55
|
+
|
|
56
|
+
_SUCCESS_HTML = (
|
|
57
|
+
b"<!doctype html><html><head><meta charset='utf-8'>"
|
|
58
|
+
b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
|
|
59
|
+
b"<h1>Signed in.</h1><p>You can close this tab.</p></body></html>"
|
|
60
|
+
)
|
|
61
|
+
_ERROR_HTML = (
|
|
62
|
+
b"<!doctype html><html><head><meta charset='utf-8'>"
|
|
63
|
+
b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
|
|
64
|
+
b"<h1>Sign-in failed.</h1><p>Check the terminal for details.</p></body></html>"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OAuthError(RuntimeError):
|
|
69
|
+
"""Raised when the OAuth flow fails at any step."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RefreshTokenInvalidError(OAuthError):
|
|
73
|
+
"""The refresh token itself is invalid/revoked — the user must re-login.
|
|
74
|
+
|
|
75
|
+
Distinguished from transient refresh failures (network, 5xx) so that
|
|
76
|
+
callers can surface a "run ``delos login``" message instead of a retry.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class LoginResult:
|
|
82
|
+
"""Outcome of a successful OAuth handshake."""
|
|
83
|
+
|
|
84
|
+
tokens: Tokens
|
|
85
|
+
user_id: str
|
|
86
|
+
org_uuid: str
|
|
87
|
+
client_id: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
91
|
+
"""Generate an RFC 7636 S256 verifier/challenge pair."""
|
|
92
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
93
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
94
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
95
|
+
return verifier, challenge
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_port_free(port: int) -> None:
|
|
99
|
+
"""Confirm the loopback port is bindable, with a clear error if not."""
|
|
100
|
+
try:
|
|
101
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
102
|
+
s.bind(("127.0.0.1", port))
|
|
103
|
+
except OSError as e:
|
|
104
|
+
msg = (
|
|
105
|
+
f"loopback port {port} is in use — close whatever is holding it "
|
|
106
|
+
f"(try `lsof -iTCP:{port}`) and re-run `delos login`"
|
|
107
|
+
)
|
|
108
|
+
raise OAuthError(msg) from e
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
112
|
+
"""One-shot request handler: captures `code`/`state` and signals the main thread."""
|
|
113
|
+
|
|
114
|
+
result: ClassVar[dict[str, str]] = {}
|
|
115
|
+
event: ClassVar[threading.Event] = threading.Event()
|
|
116
|
+
|
|
117
|
+
def do_GET(self) -> None:
|
|
118
|
+
parsed = urlparse(self.path)
|
|
119
|
+
if parsed.path != REDIRECT_PATH:
|
|
120
|
+
self.send_response(404)
|
|
121
|
+
self.end_headers()
|
|
122
|
+
return
|
|
123
|
+
params = parse_qs(parsed.query)
|
|
124
|
+
code = params.get("code", [""])[0]
|
|
125
|
+
state = params.get("state", [""])[0]
|
|
126
|
+
error = params.get("error", [""])[0]
|
|
127
|
+
if error or not code:
|
|
128
|
+
self._send_html(400, _ERROR_HTML)
|
|
129
|
+
type(self).result = {"error": error or "missing code"}
|
|
130
|
+
else:
|
|
131
|
+
self._send_html(200, _SUCCESS_HTML)
|
|
132
|
+
type(self).result = {"code": code, "state": state}
|
|
133
|
+
type(self).event.set()
|
|
134
|
+
|
|
135
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 — stdlib signature
|
|
136
|
+
"""Silence the default stderr access log."""
|
|
137
|
+
|
|
138
|
+
def _send_html(self, status: int, body: bytes) -> None:
|
|
139
|
+
self.send_response(status)
|
|
140
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
141
|
+
self.send_header("Content-Length", str(len(body)))
|
|
142
|
+
self.end_headers()
|
|
143
|
+
self.wfile.write(body)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _exchange_code(
|
|
147
|
+
web_url: str,
|
|
148
|
+
client_id: str,
|
|
149
|
+
redirect_uri: str,
|
|
150
|
+
code: str,
|
|
151
|
+
verifier: str,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""Swap the authorization code for Supabase tokens at /api/oauth/token."""
|
|
154
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
155
|
+
resp = await http.post(
|
|
156
|
+
f"{web_url}/api/oauth/token",
|
|
157
|
+
json={
|
|
158
|
+
"grant_type": "authorization_code",
|
|
159
|
+
"code": code,
|
|
160
|
+
"code_verifier": verifier,
|
|
161
|
+
"client_id": client_id,
|
|
162
|
+
"redirect_uri": redirect_uri,
|
|
163
|
+
"scope": "offline_access",
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
167
|
+
msg = f"token exchange failed: HTTP {resp.status_code}: {resp.text[:300]}"
|
|
168
|
+
raise OAuthError(msg)
|
|
169
|
+
return resp.json()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def run_login_flow(
|
|
173
|
+
cfg: Config,
|
|
174
|
+
on_message: Callable[[str], None],
|
|
175
|
+
prompt_code: Callable[[str], Awaitable[str]],
|
|
176
|
+
) -> LoginResult:
|
|
177
|
+
"""Drive the full PKCE loopback flow — returns tokens on success, raises on failure.
|
|
178
|
+
|
|
179
|
+
If the user has a verified TOTP MFA factor, runs Supabase's challenge+verify
|
|
180
|
+
flow client-side (same trick compagnon uses) to upgrade the fresh ``aal1``
|
|
181
|
+
session to ``aal2`` so RLS ``mfa_enforcement`` policies don't block writes.
|
|
182
|
+
``prompt_code`` is awaited to get the 6-digit code from the user.
|
|
183
|
+
"""
|
|
184
|
+
_check_port_free(LOOPBACK_PORT)
|
|
185
|
+
redirect_uri = f"http://127.0.0.1:{LOOPBACK_PORT}{REDIRECT_PATH}"
|
|
186
|
+
verifier, challenge = _pkce_pair()
|
|
187
|
+
state = secrets.token_urlsafe(16)
|
|
188
|
+
client_id = CLIENT_ID
|
|
189
|
+
|
|
190
|
+
params = {
|
|
191
|
+
"client_id": client_id,
|
|
192
|
+
"redirect_uri": redirect_uri,
|
|
193
|
+
"response_type": "code",
|
|
194
|
+
"code_challenge": challenge,
|
|
195
|
+
"code_challenge_method": "S256",
|
|
196
|
+
"state": state,
|
|
197
|
+
"scope": "offline_access",
|
|
198
|
+
}
|
|
199
|
+
authorize_url = f"{cfg.web_url}/authorize?{urlencode(params)}"
|
|
200
|
+
|
|
201
|
+
_CallbackHandler.result = {}
|
|
202
|
+
_CallbackHandler.event = threading.Event()
|
|
203
|
+
server = http.server.HTTPServer(("127.0.0.1", LOOPBACK_PORT), _CallbackHandler)
|
|
204
|
+
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
205
|
+
thread.start()
|
|
206
|
+
|
|
207
|
+
on_message(f"Open this URL to sign in:\n {authorize_url}")
|
|
208
|
+
with contextlib.suppress(webbrowser.Error):
|
|
209
|
+
webbrowser.open(authorize_url)
|
|
210
|
+
|
|
211
|
+
loop = asyncio.get_running_loop()
|
|
212
|
+
got_callback = await loop.run_in_executor(
|
|
213
|
+
None, _CallbackHandler.event.wait, CALLBACK_TIMEOUT_SECONDS,
|
|
214
|
+
)
|
|
215
|
+
server.server_close()
|
|
216
|
+
|
|
217
|
+
if not got_callback:
|
|
218
|
+
msg = f"no OAuth callback within {CALLBACK_TIMEOUT_SECONDS}s"
|
|
219
|
+
raise OAuthError(msg)
|
|
220
|
+
result = _CallbackHandler.result
|
|
221
|
+
if "error" in result:
|
|
222
|
+
msg = f"OAuth error: {result['error']}"
|
|
223
|
+
raise OAuthError(msg)
|
|
224
|
+
if result.get("state") != state:
|
|
225
|
+
msg = "OAuth state mismatch (possible CSRF)"
|
|
226
|
+
raise OAuthError(msg)
|
|
227
|
+
|
|
228
|
+
on_message("Exchanging authorization code…")
|
|
229
|
+
token_data = await _exchange_code(
|
|
230
|
+
cfg.web_url, client_id, redirect_uri, result["code"], verifier,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
access = str(token_data.get("supabase_access_token") or token_data.get("access_token", ""))
|
|
234
|
+
refresh = str(token_data.get("supabase_refresh_token") or token_data.get("refresh_token", ""))
|
|
235
|
+
expires_in = int(token_data.get("expires_in", DEFAULT_EXPIRES_IN))
|
|
236
|
+
|
|
237
|
+
access, refresh, expires_in = await _maybe_upgrade_to_aal2(
|
|
238
|
+
cfg, access, refresh, expires_in, on_message, prompt_code,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return LoginResult(
|
|
242
|
+
tokens=Tokens(
|
|
243
|
+
access_token=access,
|
|
244
|
+
refresh_token=refresh,
|
|
245
|
+
expires_at=int(time.time()) + expires_in,
|
|
246
|
+
),
|
|
247
|
+
user_id=str(token_data.get("user_id", "")),
|
|
248
|
+
org_uuid=str(token_data.get("org_uuid", "")),
|
|
249
|
+
client_id=client_id,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _maybe_upgrade_to_aal2(
|
|
254
|
+
cfg: Config,
|
|
255
|
+
access: str,
|
|
256
|
+
refresh: str,
|
|
257
|
+
expires_in: int,
|
|
258
|
+
on_message: Callable[[str], None],
|
|
259
|
+
prompt_code: Callable[[str], Awaitable[str]],
|
|
260
|
+
) -> tuple[str, str, int]:
|
|
261
|
+
"""If the session is aal1 and the user has a TOTP factor, run the challenge flow."""
|
|
262
|
+
if decode_aal(access) == "aal2":
|
|
263
|
+
return access, refresh, expires_in
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
factors = await list_verified_factors(cfg.supabase_url, cfg.supabase_anon_key, access)
|
|
267
|
+
except MfaError as e:
|
|
268
|
+
on_message(f"warning: couldn't list MFA factors ({e}); continuing at aal1")
|
|
269
|
+
return access, refresh, expires_in
|
|
270
|
+
|
|
271
|
+
if not factors:
|
|
272
|
+
return access, refresh, expires_in
|
|
273
|
+
|
|
274
|
+
factor = _pick_factor(factors, on_message)
|
|
275
|
+
on_message(f"MFA required — challenging factor '{factor.friendly_name}'")
|
|
276
|
+
code = (await prompt_code(f"Enter 6-digit code for {factor.friendly_name}: ")).strip()
|
|
277
|
+
try:
|
|
278
|
+
upgraded = await challenge_and_verify(
|
|
279
|
+
cfg.supabase_url, cfg.supabase_anon_key, access, factor.id, code,
|
|
280
|
+
)
|
|
281
|
+
except MfaError as e:
|
|
282
|
+
msg = f"MFA verification failed: {e}"
|
|
283
|
+
raise OAuthError(msg) from e
|
|
284
|
+
on_message("MFA verified — session upgraded to aal2.")
|
|
285
|
+
return upgraded.access_token, upgraded.refresh_token, upgraded.expires_in
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _pick_factor(factors: list[MfaFactor], on_message: Callable[[str], None]) -> MfaFactor:
|
|
289
|
+
"""Pick a factor automatically when there's only one; otherwise print the list and use the first."""
|
|
290
|
+
if len(factors) == 1:
|
|
291
|
+
return factors[0]
|
|
292
|
+
on_message("Multiple TOTP factors found — using the first:")
|
|
293
|
+
for i, f in enumerate(factors):
|
|
294
|
+
on_message(f" [{i}] {f.friendly_name}")
|
|
295
|
+
return factors[0]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
HTTP_BAD_REQUEST = 400
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def refresh_tokens(cfg: Config) -> Tokens:
|
|
302
|
+
"""Swap the refresh token for a fresh access token at /api/oauth/token.
|
|
303
|
+
|
|
304
|
+
Raises :class:`RefreshTokenInvalidError` when the server reports
|
|
305
|
+
``invalid_grant`` (token revoked, expired, or already rotated by another
|
|
306
|
+
client) — the caller should prompt for re-login. Other failures raise
|
|
307
|
+
:class:`OAuthError` and are generally transient.
|
|
308
|
+
"""
|
|
309
|
+
if not cfg.tokens.refresh_token:
|
|
310
|
+
msg = "no refresh_token stored — run `delos login` again"
|
|
311
|
+
raise RefreshTokenInvalidError(msg)
|
|
312
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
313
|
+
resp = await http.post(
|
|
314
|
+
f"{cfg.web_url}/api/oauth/token",
|
|
315
|
+
json={
|
|
316
|
+
"grant_type": "refresh_token",
|
|
317
|
+
"refresh_token": cfg.tokens.refresh_token,
|
|
318
|
+
"client_id": CLIENT_ID,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
if resp.status_code == HTTP_BAD_REQUEST:
|
|
322
|
+
with contextlib.suppress(json.JSONDecodeError, ValueError):
|
|
323
|
+
err = resp.json()
|
|
324
|
+
if isinstance(err, dict) and err.get("error") == "invalid_grant":
|
|
325
|
+
msg = str(err.get("error_description") or "refresh token is invalid or expired")
|
|
326
|
+
raise RefreshTokenInvalidError(msg)
|
|
327
|
+
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
328
|
+
msg = f"refresh failed: HTTP {resp.status_code}: {resp.text[:300]}"
|
|
329
|
+
raise OAuthError(msg)
|
|
330
|
+
data = resp.json()
|
|
331
|
+
expires_in = int(data.get("expires_in", DEFAULT_EXPIRES_IN))
|
|
332
|
+
return Tokens(
|
|
333
|
+
access_token=str(data["access_token"]),
|
|
334
|
+
refresh_token=str(data.get("refresh_token", cfg.tokens.refresh_token)),
|
|
335
|
+
expires_at=int(time.time()) + expires_in,
|
|
336
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Single source of truth for access/refresh tokens at runtime.
|
|
2
|
+
|
|
3
|
+
Models the behavior of Supabase JS's ``autoRefreshToken: true`` — the one
|
|
4
|
+
``apps/compagnon`` relies on to stay signed in "forever" without ever seeing
|
|
5
|
+
401s from expired access tokens:
|
|
6
|
+
|
|
7
|
+
* **Proactive refresh**: every time the CLI needs an access token, we check
|
|
8
|
+
the JWT's ``exp`` claim; if we're within ``LEEWAY_SECONDS`` of expiry we
|
|
9
|
+
refresh first and hand back the fresh one.
|
|
10
|
+
* **Reactive refresh**: if the server rejects an otherwise-valid-looking
|
|
11
|
+
token (401/403 — could be an admin revocation or clock skew), callers hit
|
|
12
|
+
:meth:`force_refresh` which bypasses the expiry check.
|
|
13
|
+
* **In-process safety**: an :class:`asyncio.Lock` serializes refreshes so a
|
|
14
|
+
burst of 20 concurrent requests with an expired token triggers exactly one
|
|
15
|
+
refresh, not twenty.
|
|
16
|
+
* **Cross-process safety**: :func:`config_file_lock` + re-read from disk
|
|
17
|
+
inside the critical section means two ``delos`` terminals running at once
|
|
18
|
+
can't each burn the same refresh token against Supabase and lose.
|
|
19
|
+
* **Atomic persistence**: writes go through :func:`save`, which does
|
|
20
|
+
tempfile + ``fsync`` + ``os.replace``.
|
|
21
|
+
* **Typed failure modes**: :class:`RefreshTokenInvalidError` surfaces only
|
|
22
|
+
when the refresh token itself is dead (``invalid_grant``) — callers
|
|
23
|
+
translate that into "run ``delos login``"; anything else is transient.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import base64
|
|
30
|
+
import json
|
|
31
|
+
import time
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
from .config import Tokens, config_file_lock, load, save
|
|
35
|
+
from .oauth import refresh_tokens
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from .config import Config
|
|
39
|
+
|
|
40
|
+
# Refresh this many seconds before the access token actually expires.
|
|
41
|
+
# Picked to absorb network RTT and clock drift without being so eager we
|
|
42
|
+
# churn refreshes for idle REPLs.
|
|
43
|
+
LEEWAY_SECONDS = 60
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TokenManager:
|
|
47
|
+
"""Owns the live ``Config.tokens`` for a single CLI process.
|
|
48
|
+
|
|
49
|
+
One instance per ``AuthedClient``. Not thread-safe (asyncio-only); that's
|
|
50
|
+
fine — the CLI runs one event loop. Concurrency inside the loop and
|
|
51
|
+
across sibling processes is handled explicitly via the locks below.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, cfg: Config) -> None:
|
|
55
|
+
"""Bind the manager to a ``Config``; mutations land in it in-place."""
|
|
56
|
+
self._cfg = cfg
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def cfg(self) -> Config:
|
|
61
|
+
"""The live ``Config`` — URL properties are read directly from it."""
|
|
62
|
+
return self._cfg
|
|
63
|
+
|
|
64
|
+
async def access_token(self) -> str:
|
|
65
|
+
"""Return a non-near-expiry access token, refreshing first if needed."""
|
|
66
|
+
if self._near_expiry():
|
|
67
|
+
await self._refresh()
|
|
68
|
+
return self._cfg.tokens.access_token
|
|
69
|
+
|
|
70
|
+
async def force_refresh(self) -> None:
|
|
71
|
+
"""Refresh unconditionally — call after the server returned 401/403.
|
|
72
|
+
|
|
73
|
+
Still honors the in-process + cross-process locks and the
|
|
74
|
+
disk-adoption shortcut, so a burst of 401s won't multiply refreshes.
|
|
75
|
+
"""
|
|
76
|
+
await self._refresh(force=True)
|
|
77
|
+
|
|
78
|
+
def _near_expiry(self) -> bool:
|
|
79
|
+
"""True when the access token's ``exp`` is within LEEWAY of now."""
|
|
80
|
+
exp = _jwt_exp(self._cfg.tokens.access_token) or self._cfg.tokens.expires_at
|
|
81
|
+
if not exp:
|
|
82
|
+
return True # no token, or no exp claim — treat as expired
|
|
83
|
+
return exp - int(time.time()) < LEEWAY_SECONDS
|
|
84
|
+
|
|
85
|
+
async def _refresh(self, *, force: bool = False) -> None:
|
|
86
|
+
async with self._lock:
|
|
87
|
+
# Double-check under the in-process lock: another coroutine may
|
|
88
|
+
# have just refreshed while we awaited the lock.
|
|
89
|
+
if not force and not self._near_expiry():
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Cross-process lock. Re-read the file inside it — another CLI
|
|
93
|
+
# may have refreshed and written fresh tokens since we started.
|
|
94
|
+
with config_file_lock():
|
|
95
|
+
disk = load()
|
|
96
|
+
if not force and _is_fresher(disk.tokens, self._cfg.tokens):
|
|
97
|
+
self._cfg.tokens = disk.tokens
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# We're the one doing the refresh. Use whichever refresh
|
|
101
|
+
# token is most recent (ours if unchanged, or the one from
|
|
102
|
+
# disk if another process wrote a newer pair we missed).
|
|
103
|
+
if _is_fresher(disk.tokens, self._cfg.tokens):
|
|
104
|
+
self._cfg.tokens = disk.tokens
|
|
105
|
+
|
|
106
|
+
# On failure (RefreshTokenInvalidError / OAuthError) we
|
|
107
|
+
# let the exception propagate and intentionally do NOT
|
|
108
|
+
# wipe the on-disk tokens — a stale-in-memory vs.
|
|
109
|
+
# fresh-on-disk mismatch (or a transient backend error)
|
|
110
|
+
# shouldn't cost the user their session. The caller
|
|
111
|
+
# surfaces "run delos login" and the user decides.
|
|
112
|
+
self._cfg.tokens = await refresh_tokens(self._cfg)
|
|
113
|
+
save(self._cfg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _jwt_exp(access_token: str) -> int | None:
|
|
117
|
+
"""Return the ``exp`` claim (seconds since epoch) from a JWT, or None."""
|
|
118
|
+
parts = access_token.split(".")
|
|
119
|
+
min_jwt_parts = 2
|
|
120
|
+
if len(parts) < min_jwt_parts:
|
|
121
|
+
return None
|
|
122
|
+
payload_b64 = parts[1]
|
|
123
|
+
payload_b64 += "=" * ((-len(payload_b64)) % 4)
|
|
124
|
+
try:
|
|
125
|
+
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
126
|
+
except (ValueError, json.JSONDecodeError):
|
|
127
|
+
return None
|
|
128
|
+
exp = payload.get("exp")
|
|
129
|
+
if isinstance(exp, int):
|
|
130
|
+
return exp
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_fresher(a: Tokens, b: Tokens) -> bool:
|
|
135
|
+
"""True when ``a`` represents a session that will live longer than ``b``."""
|
|
136
|
+
return bool(a.access_token) and a.expires_at > b.expires_at
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Command primitives + the global command registry.
|
|
2
|
+
|
|
3
|
+
App-specific commands live under ``apps/<name>/commands.py`` and are
|
|
4
|
+
merged on top of ``GLOBAL_COMMANDS`` by the completer and the loop.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import CommandSpec, Quit, registry_from
|
|
8
|
+
from .builtin import GLOBAL_COMMANDS
|
|
9
|
+
|
|
10
|
+
__all__ = ["GLOBAL_COMMANDS", "CommandSpec", "Quit", "registry_from"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Command primitives: the spec + the control-flow signal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
10
|
+
|
|
11
|
+
from delos_cli.ctx import Ctx
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CommandSpec:
|
|
16
|
+
"""One slash command.
|
|
17
|
+
|
|
18
|
+
``handler`` may be sync or async. ``complete`` is optional and is called
|
|
19
|
+
by the completer to suggest argument values — it receives the raw string
|
|
20
|
+
that follows the command name (may be empty).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
summary: str
|
|
25
|
+
handler: Callable[[Ctx, str], Awaitable[None] | None]
|
|
26
|
+
aliases: tuple[str, ...] = ()
|
|
27
|
+
complete: Callable[[Ctx, str], Iterable[str]] | None = None
|
|
28
|
+
args_hint: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def display_name(self) -> str:
|
|
32
|
+
"""Command name + optional arg hint for help / toolbar."""
|
|
33
|
+
return f"{self.name} {self.args_hint}".rstrip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Quit(BaseException):
|
|
37
|
+
"""Control-flow signal raised by ``/quit`` to unwind the REPL loop.
|
|
38
|
+
|
|
39
|
+
Subclasses ``BaseException`` on purpose: bare ``except Exception`` handlers
|
|
40
|
+
inside command/app code must not swallow it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def registry_from(specs: Iterable[CommandSpec]) -> dict[str, CommandSpec]:
|
|
45
|
+
"""Build a name→spec dict that also covers every alias of every spec."""
|
|
46
|
+
out: dict[str, CommandSpec] = {}
|
|
47
|
+
for spec in specs:
|
|
48
|
+
out[spec.name] = spec
|
|
49
|
+
for alias in spec.aliases:
|
|
50
|
+
out[alias] = spec
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["CommandSpec", "Quit", "registry_from"]
|