querri 1.0.2__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.
- querri/__init__.py +51 -0
- querri/_auth.py +818 -0
- querri/_base_client.py +379 -0
- querri/_client.py +395 -0
- querri/_config.py +140 -0
- querri/_convenience.py +538 -0
- querri/_exceptions.py +180 -0
- querri/_pagination.py +278 -0
- querri/_streaming.py +663 -0
- querri/_user_client.py +193 -0
- querri/_version.py +1 -0
- querri/cli/__init__.py +21 -0
- querri/cli/_app.py +246 -0
- querri/cli/_context.py +216 -0
- querri/cli/_image.py +234 -0
- querri/cli/_output.py +293 -0
- querri/cli/audit.py +68 -0
- querri/cli/auth.py +556 -0
- querri/cli/chat.py +1255 -0
- querri/cli/chats.py +358 -0
- querri/cli/dashboards.py +294 -0
- querri/cli/embed.py +220 -0
- querri/cli/files.py +214 -0
- querri/cli/keys.py +304 -0
- querri/cli/policies.py +424 -0
- querri/cli/projects.py +916 -0
- querri/cli/sharing.py +403 -0
- querri/cli/sources.py +532 -0
- querri/cli/steps.py +123 -0
- querri/cli/usage.py +99 -0
- querri/cli/users.py +223 -0
- querri/cli/views.py +449 -0
- querri/cli/whoami.py +78 -0
- querri/py.typed +0 -0
- querri/resources/__init__.py +1 -0
- querri/resources/audit.py +119 -0
- querri/resources/dashboards.py +295 -0
- querri/resources/embed.py +268 -0
- querri/resources/files.py +145 -0
- querri/resources/keys.py +191 -0
- querri/resources/policies.py +492 -0
- querri/resources/projects.py +808 -0
- querri/resources/sharing.py +330 -0
- querri/resources/sources.py +511 -0
- querri/resources/usage.py +106 -0
- querri/resources/users.py +303 -0
- querri/resources/views.py +347 -0
- querri/types/__init__.py +104 -0
- querri/types/audit.py +21 -0
- querri/types/chat.py +51 -0
- querri/types/dashboard.py +51 -0
- querri/types/data.py +61 -0
- querri/types/embed.py +38 -0
- querri/types/file.py +18 -0
- querri/types/key.py +36 -0
- querri/types/policy.py +105 -0
- querri/types/project.py +158 -0
- querri/types/sharing.py +19 -0
- querri/types/usage.py +39 -0
- querri/types/user.py +33 -0
- querri-1.0.2.dist-info/METADATA +310 -0
- querri-1.0.2.dist-info/RECORD +64 -0
- querri-1.0.2.dist-info/WHEEL +4 -0
- querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_auth.py
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""Token storage, OAuth browser flow, and credential management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import webbrowser
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from threading import Thread
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Constants
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
WORKOS_CLIENT_ID = "client_01HGV989QK3KDNN4JJNGCD87RH"
|
|
27
|
+
WORKOS_AUTHORIZE_URL = "https://api.workos.com/user_management/authorize"
|
|
28
|
+
OAUTH_TIMEOUT_SECONDS = 120
|
|
29
|
+
OAUTH_CALLBACK_PORT = 11847 # Fixed port for WorkOS redirect URI registration
|
|
30
|
+
REFRESH_BUFFER_SECONDS = 300 # 5 minutes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# TokenProfile
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class TokenProfile:
|
|
40
|
+
"""Stored credentials for a single auth profile."""
|
|
41
|
+
|
|
42
|
+
auth_type: str = "jwt" # always "jwt" for OAuth tokens
|
|
43
|
+
access_token: str = ""
|
|
44
|
+
refresh_token: str = ""
|
|
45
|
+
expires_at: str = "" # ISO 8601
|
|
46
|
+
org_id: str = ""
|
|
47
|
+
user_email: str = ""
|
|
48
|
+
user_id: str = ""
|
|
49
|
+
user_name: str = ""
|
|
50
|
+
org_name: str = ""
|
|
51
|
+
host: str = "" # server used for login (used for token refresh)
|
|
52
|
+
all_organizations: dict[str, str] = field(default_factory=dict)
|
|
53
|
+
# Active workspace state
|
|
54
|
+
active_project_id: str = ""
|
|
55
|
+
active_project_name: str = ""
|
|
56
|
+
active_chat_id: str = ""
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
"""Redact tokens to prevent accidental exposure."""
|
|
60
|
+
return (
|
|
61
|
+
f"TokenProfile(auth_type={self.auth_type!r}, "
|
|
62
|
+
f"access_token='***', refresh_token='***', "
|
|
63
|
+
f"expires_at={self.expires_at!r}, org_id={self.org_id!r}, "
|
|
64
|
+
f"org_name={self.org_name!r}, user_email={self.user_email!r}, "
|
|
65
|
+
f"user_id={self.user_id!r})"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# TokenStore
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TokenStore:
|
|
75
|
+
"""Manages persistent token storage in ``~/.querri/tokens.json``.
|
|
76
|
+
|
|
77
|
+
Schema::
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
"profiles": {
|
|
81
|
+
"default": { ...TokenProfile fields... }
|
|
82
|
+
},
|
|
83
|
+
"active_profile": "default"
|
|
84
|
+
}
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
STORE_DIR: Path = Path.home() / ".querri"
|
|
88
|
+
STORE_FILE: Path = STORE_DIR / "tokens.json"
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
profiles: dict[str, TokenProfile] | None = None,
|
|
93
|
+
active_profile: str = "default",
|
|
94
|
+
) -> None:
|
|
95
|
+
self.profiles: dict[str, TokenProfile] = profiles or {}
|
|
96
|
+
self.active_profile = active_profile
|
|
97
|
+
|
|
98
|
+
# -- Persistence --------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def load(cls) -> TokenStore:
|
|
102
|
+
"""Load the token store from disk.
|
|
103
|
+
|
|
104
|
+
Returns an empty store (without crashing) if the file is missing,
|
|
105
|
+
unreadable, or contains invalid JSON.
|
|
106
|
+
"""
|
|
107
|
+
store = cls()
|
|
108
|
+
if not cls.STORE_FILE.exists():
|
|
109
|
+
return store
|
|
110
|
+
|
|
111
|
+
# Warn if file permissions are too open
|
|
112
|
+
try:
|
|
113
|
+
mode = cls.STORE_FILE.stat().st_mode
|
|
114
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
115
|
+
print(
|
|
116
|
+
f"Warning: {cls.STORE_FILE} has overly permissive permissions "
|
|
117
|
+
f"({oct(mode & 0o777)}). Expected 0600.",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
raw = cls.STORE_FILE.read_text(encoding="utf-8")
|
|
125
|
+
data = json.loads(raw)
|
|
126
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
127
|
+
print(
|
|
128
|
+
f"Warning: Could not read token store ({exc}). "
|
|
129
|
+
"Starting with empty credentials.",
|
|
130
|
+
file=sys.stderr,
|
|
131
|
+
)
|
|
132
|
+
return store
|
|
133
|
+
|
|
134
|
+
store.active_profile = data.get("active_profile", "default")
|
|
135
|
+
for name, profile_data in data.get("profiles", {}).items():
|
|
136
|
+
if isinstance(profile_data, dict):
|
|
137
|
+
store.profiles[name] = TokenProfile(
|
|
138
|
+
auth_type=profile_data.get("auth_type", "jwt"),
|
|
139
|
+
access_token=profile_data.get("access_token", ""),
|
|
140
|
+
refresh_token=profile_data.get("refresh_token", ""),
|
|
141
|
+
expires_at=profile_data.get("expires_at", ""),
|
|
142
|
+
org_id=profile_data.get("org_id", ""),
|
|
143
|
+
user_email=profile_data.get("user_email", ""),
|
|
144
|
+
user_id=profile_data.get("user_id", ""),
|
|
145
|
+
user_name=profile_data.get("user_name", ""),
|
|
146
|
+
org_name=profile_data.get("org_name", ""),
|
|
147
|
+
host=profile_data.get("host", ""),
|
|
148
|
+
all_organizations=profile_data.get("all_organizations", {}),
|
|
149
|
+
active_project_id=profile_data.get("active_project_id", ""),
|
|
150
|
+
active_project_name=profile_data.get("active_project_name", ""),
|
|
151
|
+
active_chat_id=profile_data.get("active_chat_id", ""),
|
|
152
|
+
)
|
|
153
|
+
return store
|
|
154
|
+
|
|
155
|
+
def save(self) -> None:
|
|
156
|
+
"""Atomically write the token store to disk with secure permissions.
|
|
157
|
+
|
|
158
|
+
Writes to a temp file first, then renames to prevent corruption.
|
|
159
|
+
Skips persistence only in explicit CI environments to avoid
|
|
160
|
+
accidentally writing credentials in automated pipelines.
|
|
161
|
+
"""
|
|
162
|
+
# Only skip in clearly automated environments. The old isatty() check
|
|
163
|
+
# was too aggressive — it blocked saves from VSCode terminals, tmux,
|
|
164
|
+
# screen, WSL, Claude Code, and any piped context. CI pipelines set
|
|
165
|
+
# a CI env var; that's the right signal.
|
|
166
|
+
if os.environ.get("CI"):
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Ensure directory exists with 0700
|
|
170
|
+
os.makedirs(self.STORE_DIR, mode=0o700, exist_ok=True)
|
|
171
|
+
|
|
172
|
+
data = {
|
|
173
|
+
"profiles": {
|
|
174
|
+
name: asdict(profile) for name, profile in self.profiles.items()
|
|
175
|
+
},
|
|
176
|
+
"active_profile": self.active_profile,
|
|
177
|
+
}
|
|
178
|
+
content = json.dumps(data, indent=2)
|
|
179
|
+
|
|
180
|
+
# Atomic write: create temp file with 0600 permissions, then rename
|
|
181
|
+
tmp_path_obj = self.STORE_DIR / ".tokens_tmp"
|
|
182
|
+
try:
|
|
183
|
+
# Create file with restricted permissions — no race window
|
|
184
|
+
fd = os.open(
|
|
185
|
+
str(tmp_path_obj),
|
|
186
|
+
os.O_CREAT | os.O_WRONLY | os.O_TRUNC,
|
|
187
|
+
0o600,
|
|
188
|
+
)
|
|
189
|
+
try:
|
|
190
|
+
os.write(fd, content.encode("utf-8"))
|
|
191
|
+
finally:
|
|
192
|
+
os.close(fd)
|
|
193
|
+
|
|
194
|
+
# Atomic rename
|
|
195
|
+
os.rename(str(tmp_path_obj), str(self.STORE_FILE))
|
|
196
|
+
except OSError:
|
|
197
|
+
# Clean up temp file on failure
|
|
198
|
+
with contextlib.suppress(OSError):
|
|
199
|
+
tmp_path_obj.unlink(missing_ok=True)
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
# -- Profile management -------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def get_active_profile(self) -> TokenProfile | None:
|
|
205
|
+
"""Return the active profile, or ``None`` if not found."""
|
|
206
|
+
return self.profiles.get(self.active_profile)
|
|
207
|
+
|
|
208
|
+
def save_profile(self, name: str, profile: TokenProfile) -> None:
|
|
209
|
+
"""Persist a profile and write to disk."""
|
|
210
|
+
self.profiles[name] = profile
|
|
211
|
+
self.save()
|
|
212
|
+
|
|
213
|
+
def switch_profile(self, name: str) -> None:
|
|
214
|
+
"""Change the active profile.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
KeyError: If the named profile does not exist.
|
|
218
|
+
"""
|
|
219
|
+
if name not in self.profiles:
|
|
220
|
+
raise KeyError(
|
|
221
|
+
f"Profile {name!r} not found. Available: {list(self.profiles)}"
|
|
222
|
+
)
|
|
223
|
+
self.active_profile = name
|
|
224
|
+
self.save()
|
|
225
|
+
|
|
226
|
+
def delete_profile(self, name: str) -> None:
|
|
227
|
+
"""Remove a profile from the store.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
KeyError: If the named profile does not exist.
|
|
231
|
+
"""
|
|
232
|
+
if name not in self.profiles:
|
|
233
|
+
raise KeyError(f"Profile {name!r} not found.")
|
|
234
|
+
del self.profiles[name]
|
|
235
|
+
# If we deleted the active profile, reset to default or first available
|
|
236
|
+
if self.active_profile == name:
|
|
237
|
+
self.active_profile = next(iter(self.profiles), "default")
|
|
238
|
+
self.save()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Token refresh helpers
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def needs_refresh(profile: TokenProfile) -> bool:
|
|
247
|
+
"""Return True if the profile's access token is within 5 minutes of expiry."""
|
|
248
|
+
if not profile.expires_at:
|
|
249
|
+
return True # No expiry info — assume refresh needed
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
from datetime import datetime, timezone
|
|
253
|
+
|
|
254
|
+
expires = datetime.fromisoformat(profile.expires_at)
|
|
255
|
+
# Ensure timezone-aware comparison
|
|
256
|
+
if expires.tzinfo is None:
|
|
257
|
+
expires = expires.replace(tzinfo=timezone.utc)
|
|
258
|
+
now = datetime.now(timezone.utc)
|
|
259
|
+
remaining = (expires - now).total_seconds()
|
|
260
|
+
return remaining < REFRESH_BUFFER_SECONDS
|
|
261
|
+
except (ValueError, TypeError):
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def refresh_tokens(
|
|
266
|
+
profile: TokenProfile,
|
|
267
|
+
host: str,
|
|
268
|
+
*,
|
|
269
|
+
organization_id: str | None = None,
|
|
270
|
+
) -> TokenProfile:
|
|
271
|
+
"""Refresh an expired access token using the refresh token.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
profile: The profile whose tokens to refresh.
|
|
275
|
+
host: The Querri server host (e.g. ``https://app.querri.com``).
|
|
276
|
+
organization_id: Optional org ID to switch to during refresh.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Updated TokenProfile with new tokens.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
httpx.HTTPStatusError: On server error.
|
|
283
|
+
RuntimeError: If refresh fails.
|
|
284
|
+
"""
|
|
285
|
+
url = host.rstrip("/") + "/api/v1/auth/cli/token"
|
|
286
|
+
payload: dict[str, str] = {
|
|
287
|
+
"grant_type": "refresh_token",
|
|
288
|
+
"refresh_token": profile.refresh_token,
|
|
289
|
+
}
|
|
290
|
+
if organization_id:
|
|
291
|
+
payload["organization_id"] = organization_id
|
|
292
|
+
try:
|
|
293
|
+
response = httpx.post(url, json=payload, timeout=30.0)
|
|
294
|
+
response.raise_for_status()
|
|
295
|
+
except httpx.HTTPStatusError as exc:
|
|
296
|
+
raise RuntimeError(
|
|
297
|
+
"Token refresh failed. Please run 'querri auth login' again."
|
|
298
|
+
) from exc
|
|
299
|
+
|
|
300
|
+
data = response.json()
|
|
301
|
+
profile.access_token = data["access_token"]
|
|
302
|
+
profile.refresh_token = data.get("refresh_token", profile.refresh_token)
|
|
303
|
+
|
|
304
|
+
# Update expiry — try expires_in first, fall back to JWT exp claim
|
|
305
|
+
expires_in = data.get("expires_in")
|
|
306
|
+
if expires_in is not None:
|
|
307
|
+
from datetime import datetime, timedelta, timezone
|
|
308
|
+
|
|
309
|
+
expires = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
310
|
+
profile.expires_at = expires.isoformat()
|
|
311
|
+
else:
|
|
312
|
+
claims = _decode_jwt_payload(data["access_token"])
|
|
313
|
+
if "exp" in claims:
|
|
314
|
+
from datetime import datetime, timezone
|
|
315
|
+
|
|
316
|
+
profile.expires_at = datetime.fromtimestamp(
|
|
317
|
+
claims["exp"], tz=timezone.utc
|
|
318
|
+
).isoformat()
|
|
319
|
+
|
|
320
|
+
# Decode JWT for updated user info
|
|
321
|
+
claims = _decode_jwt_payload(profile.access_token)
|
|
322
|
+
if claims:
|
|
323
|
+
profile.user_id = claims.get("sub", profile.user_id)
|
|
324
|
+
profile.org_id = claims.get("org_id", profile.org_id)
|
|
325
|
+
profile.user_email = claims.get("email", profile.user_email)
|
|
326
|
+
|
|
327
|
+
return profile
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# JWT payload decoding (no verification — server already validated)
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _decode_jwt_payload(token: str) -> dict[str, Any]:
|
|
336
|
+
"""Base64-decode the payload segment of a JWT.
|
|
337
|
+
|
|
338
|
+
No signature verification — the server already validated the token.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
parts = token.split(".")
|
|
342
|
+
if len(parts) != 3:
|
|
343
|
+
return {}
|
|
344
|
+
# Add padding
|
|
345
|
+
payload = parts[1] + "=="
|
|
346
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
347
|
+
return json.loads(decoded) # type: ignore[no-any-return]
|
|
348
|
+
except Exception:
|
|
349
|
+
return {}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# OAuth browser flow
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class _OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
358
|
+
"""HTTP request handler for the local OAuth callback server."""
|
|
359
|
+
|
|
360
|
+
def do_GET(self) -> None: # noqa: N802
|
|
361
|
+
parsed = urlparse(self.path)
|
|
362
|
+
|
|
363
|
+
if parsed.path != "/callback":
|
|
364
|
+
self.send_response(404)
|
|
365
|
+
self.send_header("Content-Type", "text/plain")
|
|
366
|
+
self.end_headers()
|
|
367
|
+
self.wfile.write(b"Not found")
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
params = parse_qs(parsed.query)
|
|
371
|
+
server: _OAuthCallbackServer = self.server # type: ignore[assignment]
|
|
372
|
+
|
|
373
|
+
# Validate state
|
|
374
|
+
received_state = params.get("state", [""])[0]
|
|
375
|
+
if received_state != server.expected_state:
|
|
376
|
+
self.send_response(400)
|
|
377
|
+
self.send_header("Content-Type", "text/html")
|
|
378
|
+
self.end_headers()
|
|
379
|
+
self.wfile.write(
|
|
380
|
+
b"<html><body><h1>Authentication failed</h1>"
|
|
381
|
+
b"<p>Invalid state parameter. Please try again.</p>"
|
|
382
|
+
b"</body></html>"
|
|
383
|
+
)
|
|
384
|
+
server.error = "State parameter mismatch"
|
|
385
|
+
server.shutdown_flag = True
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
# Check for error from provider
|
|
389
|
+
if "error" in params:
|
|
390
|
+
error_msg = params.get("error_description", params["error"])[0]
|
|
391
|
+
self.send_response(400)
|
|
392
|
+
self.send_header("Content-Type", "text/html")
|
|
393
|
+
self.end_headers()
|
|
394
|
+
self.wfile.write(
|
|
395
|
+
f"<html><body><h1>Authentication failed</h1>"
|
|
396
|
+
f"<p>{error_msg}</p></body></html>".encode()
|
|
397
|
+
)
|
|
398
|
+
server.error = error_msg
|
|
399
|
+
server.shutdown_flag = True
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
# Extract authorization code
|
|
403
|
+
code = params.get("code", [""])[0]
|
|
404
|
+
if not code:
|
|
405
|
+
self.send_response(400)
|
|
406
|
+
self.send_header("Content-Type", "text/html")
|
|
407
|
+
self.end_headers()
|
|
408
|
+
self.wfile.write(
|
|
409
|
+
b"<html><body><h1>Authentication failed</h1>"
|
|
410
|
+
b"<p>No authorization code received.</p>"
|
|
411
|
+
b"</body></html>"
|
|
412
|
+
)
|
|
413
|
+
server.error = "No authorization code received"
|
|
414
|
+
server.shutdown_flag = True
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# Success
|
|
418
|
+
self.send_response(200)
|
|
419
|
+
self.send_header("Content-Type", "text/html")
|
|
420
|
+
self.end_headers()
|
|
421
|
+
self.wfile.write(
|
|
422
|
+
b"""<!DOCTYPE html>
|
|
423
|
+
<html lang="en">
|
|
424
|
+
<head>
|
|
425
|
+
<meta charset="utf-8">
|
|
426
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
427
|
+
<title>Querri CLI - Authenticated</title>
|
|
428
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
429
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
430
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Poppins:wght@600;700&family=Source+Sans+3:wght@400;600&display=swap"
|
|
431
|
+
rel="stylesheet">
|
|
432
|
+
<link rel="icon" href="https://querri.com/favicon.svg" type="image/svg+xml">
|
|
433
|
+
<style>
|
|
434
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
435
|
+
body{
|
|
436
|
+
font-family:'Source Sans 3','Open Sans',-apple-system,sans-serif;
|
|
437
|
+
background:#0e0e12;
|
|
438
|
+
color:#c5c8cb;
|
|
439
|
+
display:flex;align-items:center;justify-content:center;
|
|
440
|
+
min-height:100vh;
|
|
441
|
+
overflow:hidden;
|
|
442
|
+
}
|
|
443
|
+
/* animated grid background */
|
|
444
|
+
body::before{
|
|
445
|
+
content:'';position:fixed;inset:0;z-index:0;
|
|
446
|
+
background-image:
|
|
447
|
+
linear-gradient(rgba(241,90,36,.04) 1px,transparent 1px),
|
|
448
|
+
linear-gradient(90deg,rgba(241,90,36,.04) 1px,transparent 1px);
|
|
449
|
+
background-size:48px 48px;
|
|
450
|
+
animation:gridPulse 8s ease-in-out infinite;
|
|
451
|
+
}
|
|
452
|
+
@keyframes gridPulse{
|
|
453
|
+
0%,100%{opacity:.5} 50%{opacity:1}
|
|
454
|
+
}
|
|
455
|
+
/* subtle glow behind card */
|
|
456
|
+
body::after{
|
|
457
|
+
content:'';position:fixed;
|
|
458
|
+
width:600px;height:600px;
|
|
459
|
+
top:50%;left:50%;transform:translate(-50%,-50%);
|
|
460
|
+
background:radial-gradient(circle,rgba(241,90,36,.08) 0%,transparent 70%);
|
|
461
|
+
z-index:0;pointer-events:none;
|
|
462
|
+
}
|
|
463
|
+
.card{
|
|
464
|
+
position:relative;z-index:1;
|
|
465
|
+
max-width:640px;width:92vw;
|
|
466
|
+
background:rgba(22,22,28,.85);
|
|
467
|
+
backdrop-filter:blur(20px);
|
|
468
|
+
border:1px solid rgba(255,255,255,.06);
|
|
469
|
+
border-radius:16px;
|
|
470
|
+
padding:48px 52px;
|
|
471
|
+
box-shadow:0 0 80px rgba(241,90,36,.06),0 32px 64px rgba(0,0,0,.5);
|
|
472
|
+
}
|
|
473
|
+
.header{text-align:center;margin-bottom:32px}
|
|
474
|
+
.logo{margin-bottom:24px}
|
|
475
|
+
.logo img{height:28px;filter:brightness(1.1)}
|
|
476
|
+
.status{
|
|
477
|
+
display:inline-flex;align-items:center;gap:8px;
|
|
478
|
+
padding:6px 16px;
|
|
479
|
+
background:rgba(55,151,100,.1);
|
|
480
|
+
border:1px solid rgba(55,151,100,.25);
|
|
481
|
+
border-radius:100px;
|
|
482
|
+
font-family:'JetBrains Mono',monospace;
|
|
483
|
+
font-size:13px;font-weight:500;
|
|
484
|
+
color:#379764;
|
|
485
|
+
margin-bottom:16px;
|
|
486
|
+
}
|
|
487
|
+
.status .dot{
|
|
488
|
+
width:8px;height:8px;border-radius:50%;
|
|
489
|
+
background:#379764;
|
|
490
|
+
box-shadow:0 0 8px rgba(55,151,100,.6);
|
|
491
|
+
animation:pulse 2s ease-in-out infinite;
|
|
492
|
+
}
|
|
493
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
494
|
+
h1{
|
|
495
|
+
font-family:'Poppins',sans-serif;
|
|
496
|
+
font-size:24px;font-weight:700;
|
|
497
|
+
color:#fff;margin-bottom:6px;
|
|
498
|
+
}
|
|
499
|
+
.subtitle{color:#757980;font-size:14px;line-height:1.5}
|
|
500
|
+
/* terminal block */
|
|
501
|
+
.terminal{
|
|
502
|
+
text-align:left;
|
|
503
|
+
background:#0a0a0e;
|
|
504
|
+
border:1px solid rgba(255,255,255,.06);
|
|
505
|
+
border-radius:12px;
|
|
506
|
+
overflow:hidden;
|
|
507
|
+
margin-bottom:20px;
|
|
508
|
+
}
|
|
509
|
+
.terminal-bar{
|
|
510
|
+
display:flex;align-items:center;gap:6px;
|
|
511
|
+
padding:10px 16px;
|
|
512
|
+
background:rgba(255,255,255,.03);
|
|
513
|
+
border-bottom:1px solid rgba(255,255,255,.04);
|
|
514
|
+
}
|
|
515
|
+
.terminal-dot{width:10px;height:10px;border-radius:50%}
|
|
516
|
+
.terminal-dot.r{background:#ff5f57}
|
|
517
|
+
.terminal-dot.y{background:#febc2e}
|
|
518
|
+
.terminal-dot.g{background:#28c840}
|
|
519
|
+
.terminal-title{
|
|
520
|
+
flex:1;text-align:center;
|
|
521
|
+
font-family:'JetBrains Mono',monospace;
|
|
522
|
+
font-size:11px;color:#515357;
|
|
523
|
+
margin-right:42px;
|
|
524
|
+
}
|
|
525
|
+
.terminal-body{padding:20px 20px 24px}
|
|
526
|
+
.cmd{
|
|
527
|
+
padding:6px 0;
|
|
528
|
+
font-family:'JetBrains Mono',monospace;
|
|
529
|
+
font-size:13px;line-height:1.7;
|
|
530
|
+
}
|
|
531
|
+
.cmd .prompt{color:#F15A24;font-weight:700}
|
|
532
|
+
.cmd .command{color:#e4e4e7}
|
|
533
|
+
.cmd .comment{color:#515357;font-size:12px;margin-left:8px}
|
|
534
|
+
.cmd .flag{color:#41A1B6}
|
|
535
|
+
.cmd .string{color:#379764}
|
|
536
|
+
.cmd .arg{color:#757980;font-style:italic}
|
|
537
|
+
.sep{
|
|
538
|
+
border:none;border-top:1px solid rgba(255,255,255,.04);
|
|
539
|
+
margin:8px 0;
|
|
540
|
+
}
|
|
541
|
+
.section-label{
|
|
542
|
+
font-family:'JetBrains Mono',monospace;
|
|
543
|
+
font-size:11px;color:#515357;
|
|
544
|
+
text-transform:uppercase;
|
|
545
|
+
letter-spacing:1.5px;
|
|
546
|
+
margin:12px 0 4px;
|
|
547
|
+
}
|
|
548
|
+
.close-hint{
|
|
549
|
+
text-align:center;font-size:12px;color:#515357;
|
|
550
|
+
font-family:'JetBrains Mono',monospace;
|
|
551
|
+
}
|
|
552
|
+
.fade-in{animation:fadeIn .5s ease-out}
|
|
553
|
+
@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1}}
|
|
554
|
+
.typing{animation:fadeIn .5s ease-out both}
|
|
555
|
+
.typing:nth-child(2){animation-delay:.05s}
|
|
556
|
+
.typing:nth-child(3){animation-delay:.1s}
|
|
557
|
+
.typing:nth-child(4){animation-delay:.15s}
|
|
558
|
+
.typing:nth-child(5){animation-delay:.2s}
|
|
559
|
+
.typing:nth-child(6){animation-delay:.25s}
|
|
560
|
+
.typing:nth-child(7){animation-delay:.3s}
|
|
561
|
+
.typing:nth-child(8){animation-delay:.35s}
|
|
562
|
+
.typing:nth-child(9){animation-delay:.4s}
|
|
563
|
+
.typing:nth-child(10){animation-delay:.45s}
|
|
564
|
+
.typing:nth-child(11){animation-delay:.5s}
|
|
565
|
+
.typing:nth-child(12){animation-delay:.55s}
|
|
566
|
+
</style>
|
|
567
|
+
</head>
|
|
568
|
+
<body>
|
|
569
|
+
<div class="card fade-in">
|
|
570
|
+
<div class="header">
|
|
571
|
+
<div class="logo">
|
|
572
|
+
<img src="https://querri.com/querri_logo.svg" alt="Querri" height="28">
|
|
573
|
+
</div>
|
|
574
|
+
<div class="status"><span class="dot"></span> authenticated</div>
|
|
575
|
+
<h1>You're in.</h1>
|
|
576
|
+
<p class="subtitle">Return to your terminal and start exploring.</p>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
<div class="terminal">
|
|
580
|
+
<div class="terminal-bar">
|
|
581
|
+
<span class="terminal-dot r"></span>
|
|
582
|
+
<span class="terminal-dot y"></span>
|
|
583
|
+
<span class="terminal-dot g"></span>
|
|
584
|
+
<span class="terminal-title">querri-cli</span>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="terminal-body">
|
|
587
|
+
<div class="section-label typing" style="margin-top:0">verify</div>
|
|
588
|
+
<div class="cmd typing">
|
|
589
|
+
<span class="prompt">$</span>
|
|
590
|
+
<span class="command"> querri </span><span class="command">whoami</span>
|
|
591
|
+
</div>
|
|
592
|
+
|
|
593
|
+
<hr class="sep">
|
|
594
|
+
<div class="section-label typing">create & upload</div>
|
|
595
|
+
<div class="cmd typing">
|
|
596
|
+
<span class="prompt">$</span>
|
|
597
|
+
<span class="command"> querri projects create</span>
|
|
598
|
+
<span class="flag"> --name</span>
|
|
599
|
+
<span class="string"> "Q1 Analysis"</span>
|
|
600
|
+
</div>
|
|
601
|
+
<div class="cmd typing">
|
|
602
|
+
<span class="prompt">$</span>
|
|
603
|
+
<span class="command"> querri file upload</span>
|
|
604
|
+
<span class="string"> sales.csv</span>
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
<hr class="sep">
|
|
608
|
+
<div class="section-label typing">chat with your data</div>
|
|
609
|
+
<div class="cmd typing">
|
|
610
|
+
<span class="prompt">$</span>
|
|
611
|
+
<span class="command"> querri chats create</span>
|
|
612
|
+
<span class="arg"> <project-id></span>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="cmd typing">
|
|
615
|
+
<span class="prompt">$</span>
|
|
616
|
+
<span class="command"> querri chats stream</span>
|
|
617
|
+
<span class="arg"> <project> <chat></span>
|
|
618
|
+
<span class="flag"> --prompt</span>
|
|
619
|
+
<span class="string"> "summarize revenue by region"</span>
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<hr class="sep">
|
|
623
|
+
<div class="section-label typing">explore</div>
|
|
624
|
+
<div class="cmd typing">
|
|
625
|
+
<span class="prompt">$</span>
|
|
626
|
+
<span class="command"> querri</span>
|
|
627
|
+
<span class="flag"> --help</span>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<p class="close-hint">close this tab and return to your terminal</p>
|
|
633
|
+
</div>
|
|
634
|
+
</body>
|
|
635
|
+
</html>"""
|
|
636
|
+
)
|
|
637
|
+
server.auth_code = code
|
|
638
|
+
server.shutdown_flag = True
|
|
639
|
+
|
|
640
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
641
|
+
"""Suppress default request logging."""
|
|
642
|
+
pass
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class _OAuthCallbackServer(HTTPServer):
|
|
646
|
+
"""Single-use HTTP server for receiving the OAuth callback."""
|
|
647
|
+
|
|
648
|
+
def __init__(self, expected_state: str) -> None:
|
|
649
|
+
# Bind to 127.0.0.1 only, fixed port (must be registered in WorkOS)
|
|
650
|
+
super().__init__(("127.0.0.1", OAUTH_CALLBACK_PORT), _OAuthCallbackHandler)
|
|
651
|
+
self.timeout = OAUTH_TIMEOUT_SECONDS
|
|
652
|
+
self.expected_state = expected_state
|
|
653
|
+
self.auth_code: str = ""
|
|
654
|
+
self.error: str = ""
|
|
655
|
+
self.shutdown_flag: bool = False
|
|
656
|
+
|
|
657
|
+
def serve_until_callback(self) -> None:
|
|
658
|
+
"""Handle requests until a callback is received or timeout."""
|
|
659
|
+
while not self.shutdown_flag:
|
|
660
|
+
self.handle_request()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def start_oauth_flow(
|
|
664
|
+
host: str,
|
|
665
|
+
callback: Any = None,
|
|
666
|
+
*,
|
|
667
|
+
organization_id: str | None = None,
|
|
668
|
+
) -> dict[str, Any]:
|
|
669
|
+
"""Run the browser-based OAuth login flow.
|
|
670
|
+
|
|
671
|
+
1. Starts a local HTTP server on 127.0.0.1:11847 (fixed port)
|
|
672
|
+
2. Opens the browser to the WorkOS authorize URL
|
|
673
|
+
3. Waits for the callback with the authorization code
|
|
674
|
+
4. Exchanges the code for tokens via the Querri server proxy
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
host: The Querri server host (e.g. ``https://app.querri.com``).
|
|
678
|
+
callback: Unused — reserved for future progress callbacks.
|
|
679
|
+
organization_id: Optional WorkOS organization ID to scope the login.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Dict with ``access_token``, ``refresh_token``, ``expires_at``,
|
|
683
|
+
``user_id``, ``org_id``, ``user_email``.
|
|
684
|
+
|
|
685
|
+
Raises:
|
|
686
|
+
RuntimeError: If not in an interactive terminal, or if the flow
|
|
687
|
+
fails at any stage.
|
|
688
|
+
"""
|
|
689
|
+
if not sys.stdin.isatty():
|
|
690
|
+
raise RuntimeError(
|
|
691
|
+
"OAuth login requires an interactive terminal. "
|
|
692
|
+
"For CI/scripts, use QUERRI_API_KEY instead."
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# Generate state for CSRF protection
|
|
696
|
+
state = secrets.token_urlsafe(32)
|
|
697
|
+
|
|
698
|
+
# Start local callback server
|
|
699
|
+
server = _OAuthCallbackServer(expected_state=state)
|
|
700
|
+
port = server.server_address[1]
|
|
701
|
+
|
|
702
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
703
|
+
|
|
704
|
+
# Build authorize URL
|
|
705
|
+
client_id = os.environ.get("QUERRI_WORKOS_CLIENT_ID", WORKOS_CLIENT_ID)
|
|
706
|
+
params: dict[str, str] = {
|
|
707
|
+
"client_id": client_id,
|
|
708
|
+
"redirect_uri": redirect_uri,
|
|
709
|
+
"response_type": "code",
|
|
710
|
+
"state": state,
|
|
711
|
+
"provider": "authkit",
|
|
712
|
+
}
|
|
713
|
+
if organization_id:
|
|
714
|
+
params["organization_id"] = organization_id
|
|
715
|
+
authorize_params = urlencode(params)
|
|
716
|
+
authorize_url = f"{WORKOS_AUTHORIZE_URL}?{authorize_params}"
|
|
717
|
+
|
|
718
|
+
# Open browser
|
|
719
|
+
print("Opening browser for authentication...", file=sys.stderr)
|
|
720
|
+
webbrowser.open(authorize_url)
|
|
721
|
+
print(
|
|
722
|
+
f"If your browser didn't open, visit:\n {authorize_url}",
|
|
723
|
+
file=sys.stderr,
|
|
724
|
+
)
|
|
725
|
+
print("Waiting for authentication...", file=sys.stderr)
|
|
726
|
+
|
|
727
|
+
# Wait for callback in a background thread (so timeout works)
|
|
728
|
+
server_thread = Thread(target=server.serve_until_callback, daemon=True)
|
|
729
|
+
server_thread.start()
|
|
730
|
+
server_thread.join(timeout=OAUTH_TIMEOUT_SECONDS)
|
|
731
|
+
|
|
732
|
+
# Clean up server
|
|
733
|
+
server.server_close()
|
|
734
|
+
|
|
735
|
+
if server.error:
|
|
736
|
+
raise RuntimeError(f"Authentication failed: {server.error}")
|
|
737
|
+
if not server.auth_code:
|
|
738
|
+
raise RuntimeError(
|
|
739
|
+
"Authentication timed out. No callback received within "
|
|
740
|
+
f"{OAUTH_TIMEOUT_SECONDS} seconds."
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# Exchange authorization code for tokens
|
|
744
|
+
token_url = host.rstrip("/") + "/api/v1/auth/cli/token"
|
|
745
|
+
try:
|
|
746
|
+
response = httpx.post(
|
|
747
|
+
token_url,
|
|
748
|
+
json={
|
|
749
|
+
"grant_type": "authorization_code",
|
|
750
|
+
"code": server.auth_code,
|
|
751
|
+
"redirect_uri": redirect_uri,
|
|
752
|
+
},
|
|
753
|
+
timeout=30.0,
|
|
754
|
+
)
|
|
755
|
+
response.raise_for_status()
|
|
756
|
+
except httpx.HTTPStatusError as exc:
|
|
757
|
+
detail = ""
|
|
758
|
+
with contextlib.suppress(Exception):
|
|
759
|
+
detail = exc.response.text[:200]
|
|
760
|
+
raise RuntimeError(
|
|
761
|
+
f"Token exchange failed (HTTP {exc.response.status_code}). {detail}"
|
|
762
|
+
) from exc
|
|
763
|
+
|
|
764
|
+
data = response.json()
|
|
765
|
+
access_token = data["access_token"]
|
|
766
|
+
refresh_token = data.get("refresh_token", "")
|
|
767
|
+
|
|
768
|
+
# Calculate expiry
|
|
769
|
+
expires_at = ""
|
|
770
|
+
expires_in = data.get("expires_in")
|
|
771
|
+
if expires_in is not None:
|
|
772
|
+
from datetime import datetime, timedelta, timezone
|
|
773
|
+
|
|
774
|
+
expires = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
775
|
+
expires_at = expires.isoformat()
|
|
776
|
+
else:
|
|
777
|
+
# WorkOS may not return expires_in; decode exp from JWT instead
|
|
778
|
+
claims_for_exp = _decode_jwt_payload(data["access_token"])
|
|
779
|
+
if "exp" in claims_for_exp:
|
|
780
|
+
from datetime import datetime, timezone
|
|
781
|
+
|
|
782
|
+
expires_at = datetime.fromtimestamp(
|
|
783
|
+
claims_for_exp["exp"], tz=timezone.utc
|
|
784
|
+
).isoformat()
|
|
785
|
+
|
|
786
|
+
# Extract user info from response (WorkOS returns user object alongside tokens)
|
|
787
|
+
# Fall back to JWT claims if user object not present
|
|
788
|
+
user_info = data.get("user") or {}
|
|
789
|
+
claims = _decode_jwt_payload(access_token)
|
|
790
|
+
|
|
791
|
+
user_email = user_info.get("email") or claims.get("email", "")
|
|
792
|
+
user_id = user_info.get("id") or claims.get("sub", "")
|
|
793
|
+
org_id = data.get("organization_id") or claims.get("org_id", "")
|
|
794
|
+
org_name = claims.get("urn:querri:organization_name", "")
|
|
795
|
+
first_name = user_info.get("first_name", "")
|
|
796
|
+
last_name = user_info.get("last_name", "")
|
|
797
|
+
full_name = (
|
|
798
|
+
f"{first_name} {last_name}".strip()
|
|
799
|
+
if first_name or last_name
|
|
800
|
+
else claims.get("urn:querri:full_name", "")
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# org_name from server response (looked up from WorkOS)
|
|
804
|
+
all_organizations = data.get("all_organizations") or {}
|
|
805
|
+
if not org_name and org_id:
|
|
806
|
+
org_name = all_organizations.get(org_id, org_name)
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
"access_token": access_token,
|
|
810
|
+
"refresh_token": refresh_token,
|
|
811
|
+
"expires_at": expires_at,
|
|
812
|
+
"user_id": user_id,
|
|
813
|
+
"org_id": org_id,
|
|
814
|
+
"org_name": org_name,
|
|
815
|
+
"user_email": user_email,
|
|
816
|
+
"user_name": full_name,
|
|
817
|
+
"all_organizations": all_organizations,
|
|
818
|
+
}
|