AQIT 0.1.0
Loading...
Searching...
No Matches
auth_config.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2"""CLI account + token storage (~/.aquin/config.json). One token per Aquin account."""
3from __future__ import annotations
4
5import json
6import os
7import sys
8import time
9from datetime import datetime, timezone
10from pathlib import Path
11from typing import Any
12
13_CONFIG_PATH = Path.home() / ".aquin" / "config.json"
14_BASE_URL_DEFAULT = "https://api.aquin.app"
15# Re-check saved tokens against cloud at most this often (seconds).
16_WHOAMI_CACHE_TTL_S = 15 * 60
17_MIN_TOKEN_LEN = 20 # aq- + 16 hex bytes = 19; allow a little headroom for future formats
19
20def config_path() -> Path:
21 return _CONFIG_PATH
22
23
24def _base_url() -> str:
25 return os.environ.get("AQUIN_BASE_URL", _BASE_URL_DEFAULT).rstrip("/")
26
27
28def safe_print(msg: str, *, file=None) -> None:
29 """Print without crashing on Windows cp1252 consoles (no fancy unicode)."""
30 stream = file or sys.stdout
31 text = (
32 str(msg)
33 .replace("\u2192", "->")
34 .replace("\u2014", "-")
35 .replace("\u2013", "-")
36 .replace("\u2026", "...")
37 )
38 try:
39 print(text, file=stream)
40 except UnicodeEncodeError:
41 enc = getattr(stream, "encoding", None) or "utf-8"
42 print(text.encode(enc, errors="replace").decode(enc, errors="replace"), file=stream)
43
44
45def normalize_cli_token(raw: str) -> str:
46 """Strip whitespace / accidental 'Bearer ' prefix."""
47 key = (raw or "").strip()
48 if key.lower().startswith("bearer "):
49 key = key[7:].strip()
50 return key
51
52
53def validate_cli_token_format(raw: str) -> str | None:
54 """
55 Return an error message if the token is obviously not an Aquin CLI token.
56 Returns None when the shape looks acceptable (still must pass whoami).
57 """
58 key = normalize_cli_token(raw)
59 if not key:
60 return "No token entered."
61 if not key.startswith("aq-"):
62 return (
63 "Invalid CLI token: must start with aq- "
64 "(copy it from aquin.app Profile -> Account -> CLI token)."
65 )
66 if len(key) < _MIN_TOKEN_LEN:
67 return "Invalid CLI token: too short."
68 if any(ch.isspace() for ch in key):
69 return "Invalid CLI token: must not contain spaces."
70 return None
71
72
73def load_config() -> dict[str, Any]:
74 if not _CONFIG_PATH.exists():
75 return {"accounts": {}}
76 try:
77 data = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
78 if not isinstance(data, dict):
79 return {"accounts": {}}
80 except Exception:
81 return {"accounts": {}}
82 return _migrate_legacy(data)
83
84
85def save_config(cfg: dict[str, Any]) -> None:
86 _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
87 _CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
88
90def _migrate_legacy(cfg: dict[str, Any]) -> dict[str, Any]:
91 accounts = cfg.get("accounts")
92 if isinstance(accounts, dict) and accounts:
93 cfg.setdefault("active_account", "")
94 return cfg
95 key = str(cfg.get("api_key") or "").strip()
96 accounts_dict: dict[str, Any] = {}
97 if key:
98 label = str(cfg.get("active_account") or "saved").strip() or "saved"
99 accounts_dict[label] = {
100 "api_key": key,
101 "name": cfg.get("account_name"),
102 "email": cfg.get("account_email") if "@" in str(cfg.get("account_email") or "") else None,
103 }
104 cfg["active_account"] = label
105 cfg["accounts"] = accounts_dict
106 cfg.pop("api_key", None)
107 return cfg
108
109
110def _account_id(email: str | None, name: str | None, user_id: str | None) -> str:
111 if email and "@" in email:
112 return email.strip().lower()
113 if user_id:
114 return user_id
115 if name:
116 return name.strip().lower().replace(" ", "-")
117 return "account"
118
119
120def active_account_id(cfg: dict[str, Any] | None = None) -> str:
121 cfg = cfg or load_config()
122 return str(cfg.get("active_account") or "").strip()
123
125def list_accounts(cfg: dict[str, Any] | None = None) -> list[dict[str, str]]:
126 cfg = cfg or load_config()
127 active = active_account_id(cfg)
128 rows: list[dict[str, str]] = []
129 for aid, meta in (cfg.get("accounts") or {}).items():
130 if not isinstance(meta, dict):
131 continue
132 label = str(meta.get("email") or meta.get("name") or aid)
133 rows.append({
134 "id": str(aid),
135 "label": label,
136 "name": str(meta.get("name") or ""),
137 "email": str(meta.get("email") or ""),
138 "active": "yes" if aid == active else "",
139 })
140 return rows
141
142
143def resolve_api_key(*, allow_missing: bool = False, explicit: str | None = None) -> str:
144 if explicit and explicit.strip():
145 return normalize_cli_token(explicit)
146 env = os.environ.get("AQUIN_API_KEY", "").strip()
147 if env:
148 return normalize_cli_token(env)
149 cfg = load_config()
150 aid = active_account_id(cfg)
151 accounts = cfg.get("accounts") or {}
152 if aid and isinstance(accounts.get(aid), dict):
153 key = str(accounts[aid].get("api_key") or "").strip()
154 if key:
155 return normalize_cli_token(key)
156 if accounts:
157 first = next(iter(accounts.values()))
158 if isinstance(first, dict):
159 key = str(first.get("api_key") or "").strip()
160 if key:
161 return normalize_cli_token(key)
162 if not allow_missing:
163 safe_print("Not logged in. Run: aquin login", file=sys.stderr)
164 sys.exit(1)
165 return ""
166
167
168def fetch_whoami(api_key: str, base_url: str | None = None) -> dict[str, Any]:
169 import httpx
170
171 key = normalize_cli_token(api_key)
173 if fmt_err:
174 return {"ok": False, "error": "invalid_format", "hint": fmt_err}
175
176 url = (base_url or _base_url()).rstrip("/")
177 try:
178 resp = httpx.get(
179 f"{url}/api/sdk/whoami",
180 headers={"Authorization": f"Bearer {key}"},
181 timeout=15.0,
182 verify=False,
183 )
184 except Exception as exc:
185 return {
186 "ok": False,
187 "error": "network",
188 "detail": str(exc),
189 "hint": "Could not reach Aquin to verify the token.",
190 }
191
192 if resp.status_code == 401:
193 try:
194 body = resp.json()
195 except Exception:
196 body = {}
197 hint = body.get("hint") or (
198 "Invalid CLI token. Copy a fresh aq- token from aquin.app "
199 "(Profile -> Account -> CLI token), then run aquin login --force."
200 )
201 return {
202 "ok": False,
203 "error": body.get("error") or "Unauthorized",
204 "revoked_at": body.get("revoked_at"),
205 "hint": str(hint).replace("\u2192", "->"),
206 }
207
208 if resp.status_code != 200:
209 return {
210 "ok": False,
211 "error": f"HTTP {resp.status_code}",
212 "hint": f"Token verification failed (HTTP {resp.status_code}).",
213 }
214
215 try:
216 data = resp.json()
217 except Exception:
218 return {
219 "ok": False,
220 "error": "bad_response",
221 "hint": "Token verification returned an invalid response.",
222 }
223
224 if not isinstance(data, dict):
225 return {
226 "ok": False,
227 "error": "bad_response",
228 "hint": "Token verification returned an invalid response.",
229 }
230
231 user_id = data.get("user_id")
232 email = data.get("email")
233 name = data.get("name")
234 if not user_id and not email and not name:
235 return {
236 "ok": False,
237 "error": "empty_identity",
238 "hint": "Token verification succeeded but returned no account identity.",
239 }
240
241 return {
242 "ok": True,
243 "user_id": user_id,
244 "email": email,
245 "name": name,
246 "avatar_url": data.get("avatar_url"),
247 }
248
249
250def format_account_label(whoami: dict[str, Any]) -> str:
251 email = str(whoami.get("email") or "").strip()
252 name = str(whoami.get("name") or "").strip()
253 if name and email:
254 return f"{name} ({email})"
255 return email or name or "?"
256
257
258def cached_account_label(cfg: dict[str, Any] | None = None) -> str:
259 """Account label from saved login metadata - no network."""
260 cfg = cfg or load_config()
261 aid = active_account_id(cfg)
262 accounts = cfg.get("accounts") or {}
263 meta = accounts.get(aid) if aid else None
264 if not isinstance(meta, dict) and accounts:
265 meta = next(iter(accounts.values()), None)
266 if not isinstance(meta, dict):
267 return "?"
268 name = str(meta.get("name") or "").strip()
269 email = str(meta.get("email") or "").strip()
270 if name and email:
271 return f"{name} ({email})"
272 return email or name or str(aid or "?")
273
274
275def format_revoked_ago(revoked_at: str) -> str:
276 try:
277 raw = revoked_at.replace("Z", "+00:00")
278 dt = datetime.fromisoformat(raw)
279 if dt.tzinfo is None:
280 dt = dt.replace(tzinfo=timezone.utc)
281 delta = datetime.now(timezone.utc) - dt.astimezone(timezone.utc)
282 secs = int(max(0, delta.total_seconds()))
283 if secs < 60:
284 return "just now"
285 mins = secs // 60
286 if mins < 60:
287 return f"{mins} minute{'s' if mins != 1 else ''} ago"
288 hours = mins // 60
289 if hours < 48:
290 return f"{hours} hour{'s' if hours != 1 else ''} ago"
291 days = hours // 24
292 return f"{days} day{'s' if days != 1 else ''} ago"
293 except Exception:
294 return "recently"
295
296
297def _cache_whoami_ok(api_key: str, whoami: dict[str, Any]) -> None:
298 """Remember a successful whoami so we do not hit the network every command."""
299 cfg = load_config()
300 aid = active_account_id(cfg)
301 accounts = dict(cfg.get("accounts") or {})
302 meta = accounts.get(aid) if aid else None
303 if not isinstance(meta, dict):
304 return
305 if normalize_cli_token(str(meta.get("api_key") or "")) != normalize_cli_token(api_key):
306 return
307 meta = dict(meta)
308 meta["verified_at"] = time.time()
309 if whoami.get("email"):
310 meta["email"] = whoami.get("email")
311 if whoami.get("name"):
312 meta["name"] = whoami.get("name")
313 if whoami.get("user_id"):
314 meta["user_id"] = whoami.get("user_id")
315 if whoami.get("avatar_url"):
316 meta["avatar_url"] = whoami.get("avatar_url")
317 accounts[aid] = meta
318 cfg["accounts"] = accounts
319 save_config(cfg)
320
321
322def _cached_whoami_fresh(api_key: str, *, ttl_s: float = _WHOAMI_CACHE_TTL_S) -> bool:
323 cfg = load_config()
324 aid = active_account_id(cfg)
325 accounts = cfg.get("accounts") or {}
326 meta = accounts.get(aid) if aid else None
327 if not isinstance(meta, dict):
328 return False
329 if normalize_cli_token(str(meta.get("api_key") or "")) != normalize_cli_token(api_key):
330 return False
331 try:
332 verified_at = float(meta.get("verified_at") or 0)
333 except (TypeError, ValueError):
334 return False
335 return verified_at > 0 and (time.time() - verified_at) < ttl_s
336
337
338def require_valid_login(*, force_refresh: bool = False) -> str:
339 """
340 Ensure a CLI token is present AND valid with Aquin cloud.
341 Exits the process on failure. Returns the verified api key.
342 """
343 key = resolve_api_key(allow_missing=False)
344 fmt_err = validate_cli_token_format(key)
345 if fmt_err:
346 safe_print(fmt_err, file=sys.stderr)
347 safe_print("Run: aquin login --force", file=sys.stderr)
348 sys.exit(1)
349
350 if not force_refresh and _cached_whoami_fresh(key):
351 return key
352
353 whoami = fetch_whoami(key)
354 if whoami.get("ok"):
355 _cache_whoami_ok(key, whoami)
356 return key
357
358 if whoami.get("revoked_at"):
359 ago = format_revoked_ago(str(whoami["revoked_at"]))
360 safe_print(f"Your CLI token was revoked {ago}.", file=sys.stderr)
361 safe_print("Regenerate at aquin.app (Profile -> Account -> CLI token), then:", file=sys.stderr)
362 safe_print(" aquin login --force", file=sys.stderr)
363 sys.exit(1)
364
365 if whoami.get("error") == "network":
366 # Offline: allow local GPU work if we previously verified this token.
367 if _cached_whoami_fresh(key, ttl_s=7 * 24 * 3600):
368 return key
370 whoami.get("hint") or "Could not reach Aquin to verify your CLI token.",
371 file=sys.stderr,
372 )
373 safe_print("Check your network, or run: aquin login --force", file=sys.stderr)
374 sys.exit(1)
375
377 return key # unreachable
378
379
380def print_token_rejected(api_key: str | None = None) -> None:
381 """User-facing auth failure with revoked-at hint when available."""
382 safe_print("Cannot connect to Aquin: your CLI token was rejected.", file=sys.stderr)
383 if api_key:
384 status = fetch_whoami(api_key)
385 if status.get("revoked_at"):
386 ago = format_revoked_ago(str(status["revoked_at"]))
387 safe_print(f"This token was revoked {ago}.", file=sys.stderr)
388 elif status.get("hint"):
389 safe_print(str(status["hint"]), file=sys.stderr)
390 safe_print("", file=sys.stderr)
391 safe_print("Run: aquin login --force (paste a fresh aq- token)", file=sys.stderr)
392 safe_print(" aquin switch (use another saved account)", file=sys.stderr)
393 safe_print(" aquin.app/profile (Profile -> Account -> CLI token)", file=sys.stderr)
394 sys.exit(1)
395
396
397def save_login(api_key: str, *, whoami: dict[str, Any] | None = None) -> str:
398 key = normalize_cli_token(api_key)
399 fmt_err = validate_cli_token_format(key)
400 if fmt_err:
401 raise ValueError(fmt_err)
402
403 info = whoami if whoami and whoami.get("ok") else fetch_whoami(key)
404 if not info.get("ok"):
405 if info.get("revoked_at"):
406 ago = format_revoked_ago(str(info["revoked_at"]))
407 raise ValueError(f"Token was revoked {ago}. Regenerate at aquin.app and try again.")
408 hint = info.get("hint") or "Invalid CLI token."
409 raise ValueError(str(hint).replace("\u2192", "->"))
410
411 aid = _account_id(
412 str(info.get("email") or ""),
413 str(info.get("name") or ""),
414 str(info.get("user_id") or ""),
415 )
416 cfg = load_config()
417 accounts = dict(cfg.get("accounts") or {})
418 accounts[aid] = {
419 "api_key": key,
420 "email": info.get("email"),
421 "name": info.get("name"),
422 "user_id": info.get("user_id"),
423 "avatar_url": info.get("avatar_url"),
424 "saved_at": datetime.now(timezone.utc).isoformat(),
425 "verified_at": time.time(),
426 }
427 cfg["accounts"] = accounts
428 cfg["active_account"] = aid
429 save_config(cfg)
430 return aid
431
432
433def switch_account(account_id: str) -> dict[str, str]:
434 cfg = load_config()
435 accounts = cfg.get("accounts") or {}
436 aid = account_id.strip().lower()
437 if aid not in accounts:
438 for k in accounts:
439 meta = accounts[k]
440 if not isinstance(meta, dict):
441 continue
442 email = str(meta.get("email") or "").lower()
443 if email == aid or k.lower() == aid:
444 aid = k
445 break
446 else:
447 raise ValueError(
448 f"No CLI token saved for '{account_id}' on this machine.\n"
449 f"Add that account's token: aquin switch --add"
450 )
451
452 cfg["active_account"] = aid
453 save_config(cfg)
454 meta = accounts[aid]
455 return {
456 "id": aid,
457 "label": format_account_label(meta),
458 "api_key": str(meta.get("api_key") or ""),
459 }
460
461
462def logout_local(*, account_id: str | None = None) -> None:
463 cfg = load_config()
464 accounts = dict(cfg.get("accounts") or {})
465 if account_id:
466 aid = account_id.strip().lower()
467 keys = [
468 k
469 for k in accounts
470 if k.lower() == aid or str((accounts[k] or {}).get("email") or "").lower() == aid
471 ]
472 for k in keys:
473 accounts.pop(k, None)
474 if active_account_id(cfg) in keys:
475 cfg.pop("active_account", None)
476 else:
477 aid = active_account_id(cfg)
478 if aid:
479 accounts.pop(aid, None)
480 cfg.pop("active_account", None)
481 cfg["accounts"] = accounts
482 if accounts and not cfg.get("active_account"):
483 cfg["active_account"] = next(iter(accounts))
484 save_config(cfg)
list[dict[str, str]] list_accounts(dict[str, Any]|None cfg=None)
None safe_print(str msg, *, file=None)
str require_valid_login(*, bool force_refresh=False)
None logout_local(*, str|None account_id=None)
bool _cached_whoami_fresh(str api_key, *, float ttl_s=_WHOAMI_CACHE_TTL_S)
dict[str, Any] load_config()
None print_token_rejected(str|None api_key=None)
str save_login(str api_key, *, dict[str, Any]|None whoami=None)
str _account_id(str|None email, str|None name, str|None user_id)
dict[str, Any] fetch_whoami(str api_key, str|None base_url=None)
dict[str, str] switch_account(str account_id)
str active_account_id(dict[str, Any]|None cfg=None)
None save_config(dict[str, Any] cfg)
str format_account_label(dict[str, Any] whoami)
dict[str, Any] _migrate_legacy(dict[str, Any] cfg)
str cached_account_label(dict[str, Any]|None cfg=None)
str resolve_api_key(*, bool allow_missing=False, str|None explicit=None)
str format_revoked_ago(str revoked_at)
str|None validate_cli_token_format(str raw)
None _cache_whoami_ok(str api_key, dict[str, Any] whoami)
str normalize_cli_token(str raw)