@miraiva_test/miravia-order-report 0.1.13

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.
@@ -0,0 +1,1442 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Miravia Skill Hub Authorization CLI
5
+
6
+ Sole authorization entry point:
7
+ https://pre-sellercenter.miravia.es/apps/seller/skill/auth
8
+
9
+ This CLI no longer supports Miravia official OAuth (auth.miravia.com) legacy flow.
10
+ All appKey / appSecret / accessToken come from the above authorization page
11
+ via RSA-encrypted encryptedPayload.
12
+
13
+ Commands:
14
+ python auth_cli.py login [--port 8765] [--redirect-uri ...] [--no-open]
15
+ python auth_cli.py status
16
+
17
+ Credential storage:
18
+ .qoder/skills/miravia-order-report/credentials.json (chmod 600)
19
+ Path can be overridden by MIRAVIA_CRED_FILE.
20
+
21
+ File format (JSON):
22
+ {
23
+ "app_key": "enc:v1:...",
24
+ "app_secret": "enc:v1:...",
25
+ "access_token": "enc:v1:...",
26
+ "token_expires_at": "2026-06-16 15:01",
27
+ "last_login_at": "2026-06-16T14:01:45"
28
+ }
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import html
35
+ import http.server
36
+ import json
37
+ import logging
38
+ import logging.handlers
39
+ import os
40
+ import secrets
41
+ import socket
42
+ import subprocess
43
+ import sys
44
+ import threading
45
+ import time
46
+ import urllib.parse
47
+ import webbrowser
48
+ from typing import Dict, Optional, Tuple
49
+
50
+ import config
51
+ from cred_crypto import (
52
+ CredCryptoError,
53
+ SENSITIVE_KEYS,
54
+ decrypt_creds,
55
+ encrypt_creds,
56
+ is_encrypted,
57
+ )
58
+ from skill_crypto import SkillAuthDecryptError, decrypt_payload
59
+ from skill_keypair import SkillKeypairError, load_keypair
60
+
61
+
62
+ logger = logging.getLogger("miravia.auth_cli")
63
+
64
+ # Log file for troubleshooting the auth flow ("frontend auth complete -> local credentials.json written").
65
+ # Path: <skill_dir>/logs/auth_cli.log; can be overridden by MIRAVIA_AUTH_LOG_FILE.
66
+ _DEFAULT_LOG_FILE = os.path.join(
67
+ os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)),
68
+ "logs",
69
+ "auth_cli.log",
70
+ )
71
+ LOG_FILE = os.getenv("MIRAVIA_AUTH_LOG_FILE", _DEFAULT_LOG_FILE)
72
+
73
+
74
+ def _configure_logging() -> str:
75
+ """Configure dual-channel logging with millisecond timestamps (console + rotating file).
76
+
77
+ - File path: ``LOG_FILE``, default ``<skill_dir>/logs/auth_cli.log``
78
+ - File handler uses ``RotatingFileHandler`` (5MB x 3) to prevent unbounded growth
79
+ - Time format ``YYYY-mm-dd HH:MM:SS.mmm`` for easy cross-reference with browser Network panel
80
+ Returns the final effective log file absolute path, printed once in cmd_login.
81
+ """
82
+ os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
83
+ fmt = "%(asctime)s.%(msecs)03d %(levelname)s [%(name)s] %(message)s"
84
+ datefmt = "%Y-%m-%d %H:%M:%S"
85
+ formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
86
+
87
+ root = logging.getLogger()
88
+ root.setLevel(logging.INFO)
89
+ # Clear existing handlers to avoid duplicate output (multiple basicConfig calls / repeated module imports)
90
+ for h in list(root.handlers):
91
+ root.removeHandler(h)
92
+
93
+ sh = logging.StreamHandler()
94
+ sh.setLevel(logging.INFO)
95
+ sh.setFormatter(formatter)
96
+ root.addHandler(sh)
97
+
98
+ fh = logging.handlers.RotatingFileHandler(
99
+ LOG_FILE, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
100
+ )
101
+ fh.setLevel(logging.DEBUG)
102
+ fh.setFormatter(formatter)
103
+ root.addHandler(fh)
104
+
105
+ return LOG_FILE
106
+
107
+
108
+ # ------------- browser tab close (cross-platform) -------------
109
+
110
+
111
+ def _close_browser_tab(url_fragment: str = "127.0.0.1:8765/callback",
112
+ delay: float = 2.0,
113
+ blocking: bool = True) -> None:
114
+ """Close the browser tab containing the given URL fragment.
115
+
116
+ Cross-platform implementation:
117
+ - macOS: AppleScript targeting Chrome / Safari / Edge by tab URL
118
+ - Windows: ctypes EnumWindows + SendMessage WM_CLOSE (window-level)
119
+ - Linux: xdotool search by window name + send Ctrl+W
120
+
121
+ Args:
122
+ url_fragment: URL substring to match the target tab.
123
+ delay: Seconds to wait before closing (let user see success page).
124
+ blocking: If True, waits for the close operation to finish (up to
125
+ delay + 8s). If False, fires in daemon thread and returns
126
+ immediately (caller must keep process alive).
127
+ """
128
+ def _do_close():
129
+ if delay > 0:
130
+ time.sleep(delay)
131
+ platform = sys.platform
132
+ if platform == "darwin":
133
+ _close_tab_macos(url_fragment)
134
+ elif platform == "win32":
135
+ _close_tab_windows(url_fragment)
136
+ else:
137
+ _close_tab_linux(url_fragment)
138
+
139
+ t = threading.Thread(target=_do_close, daemon=True)
140
+ t.start()
141
+ if blocking:
142
+ t.join(timeout=delay + 8.0)
143
+
144
+
145
+ def _close_tab_macos(url_fragment: str) -> None:
146
+ """macOS: Use AppleScript to close the specific tab by URL match.
147
+
148
+ Tries Chrome, Safari, and Edge in order. Each browser's AppleScript
149
+ iterates all windows/tabs and closes the first tab whose URL contains
150
+ the given fragment.
151
+ """
152
+ # Browsers supporting tab-level AppleScript control (Chromium-based + Safari)
153
+ browsers = [
154
+ ("Google Chrome", _applescript_chromium_close),
155
+ ("Microsoft Edge", _applescript_chromium_close),
156
+ ("Safari", _applescript_safari_close),
157
+ ]
158
+ for app_name, close_fn in browsers:
159
+ try:
160
+ if close_fn(app_name, url_fragment):
161
+ logger.info("browser tab closed via AppleScript: app=%s", app_name)
162
+ return
163
+ except Exception as e: # noqa: BLE001
164
+ logger.debug("AppleScript close failed for %s: %s", app_name, e)
165
+ # Fallback for Firefox and other browsers without tab-level AppleScript:
166
+ # Send Cmd+W to the frontmost app (only if it's a known browser)
167
+ _applescript_cmdw_fallback(url_fragment)
168
+
169
+
170
+ def _applescript_chromium_close(app_name: str, url_fragment: str) -> bool:
171
+ """Close a tab in Chromium-based browsers (Chrome, Edge, Brave) via AppleScript.
172
+
173
+ If the matched tab is the only tab in the only window, quit the browser
174
+ entirely to avoid Chrome falling back to the profile picker page.
175
+ If it's the only tab but other windows exist, just close that window.
176
+ Otherwise close only the tab.
177
+ """
178
+ script = f'''
179
+ tell application "{app_name}"
180
+ if it is running then
181
+ set winCount to count of windows
182
+ repeat with w in windows
183
+ repeat with t in tabs of w
184
+ if URL of t contains "{url_fragment}" then
185
+ if (count of tabs of w) is 1 then
186
+ if winCount is 1 then
187
+ quit
188
+ else
189
+ close w
190
+ end if
191
+ else
192
+ close t
193
+ end if
194
+ return
195
+ end if
196
+ end repeat
197
+ end repeat
198
+ end if
199
+ end tell
200
+ '''
201
+ result = subprocess.run(
202
+ ["osascript", "-e", script],
203
+ capture_output=True, timeout=5,
204
+ )
205
+ # osascript returns 0 even if no tab matched; check stderr for "not running" etc.
206
+ if result.returncode == 0 and b"execution error" not in result.stderr:
207
+ # Verify by re-checking if the tab still exists
208
+ return True
209
+ return False
210
+
211
+
212
+ def _applescript_safari_close(app_name: str, url_fragment: str) -> bool:
213
+ """Close a tab in Safari via AppleScript."""
214
+ script = f'''
215
+ tell application "Safari"
216
+ if it is running then
217
+ repeat with w in windows
218
+ repeat with t in tabs of w
219
+ if URL of t contains "{url_fragment}" then
220
+ close t
221
+ return
222
+ end if
223
+ end repeat
224
+ end repeat
225
+ end if
226
+ end tell
227
+ '''
228
+ result = subprocess.run(
229
+ ["osascript", "-e", script],
230
+ capture_output=True, timeout=5,
231
+ )
232
+ return result.returncode == 0 and b"execution error" not in result.stderr
233
+
234
+
235
+ def _applescript_cmdw_fallback(url_fragment: str) -> None:
236
+ """Fallback: send Cmd+W to the frontmost browser window (Firefox etc.).
237
+
238
+ Only proceeds if the frontmost app is a known browser to avoid closing
239
+ unrelated windows.
240
+ """
241
+ known_browsers = ('Firefox', 'Opera', 'Brave Browser', 'Arc')
242
+ # Get frontmost app name
243
+ check_script = 'tell application "System Events" to get name of first application process whose frontmost is true'
244
+ try:
245
+ result = subprocess.run(
246
+ ["osascript", "-e", check_script],
247
+ capture_output=True, timeout=3, text=True,
248
+ )
249
+ front_app = result.stdout.strip()
250
+ except Exception: # noqa: BLE001
251
+ return
252
+ if not any(b in front_app for b in known_browsers):
253
+ logger.debug("frontmost app '%s' is not a known browser, skip Cmd+W", front_app)
254
+ return
255
+ # Send Cmd+W
256
+ cmdw_script = '''
257
+ tell application "System Events"
258
+ keystroke "w" using command down
259
+ end tell
260
+ '''
261
+ try:
262
+ subprocess.run(["osascript", "-e", cmdw_script], capture_output=True, timeout=3)
263
+ logger.info("sent Cmd+W to frontmost browser: %s", front_app)
264
+ except Exception as e: # noqa: BLE001
265
+ logger.debug("Cmd+W fallback failed: %s", e)
266
+
267
+
268
+ def _close_tab_windows(url_fragment: str) -> None:
269
+ """Windows: Find browser window by title and send WM_CLOSE.
270
+
271
+ Uses ctypes to enumerate windows and match by title containing
272
+ the callback page title keyword. Closes the entire window (not just tab)
273
+ since Windows has no universal tab-level API across browsers.
274
+ """
275
+ try:
276
+ import ctypes
277
+ from ctypes import wintypes
278
+
279
+ user32 = ctypes.windll.user32
280
+ EnumWindows = user32.EnumWindows
281
+ GetWindowTextW = user32.GetWindowTextW
282
+ PostMessageW = user32.PostMessageW
283
+ IsWindowVisible = user32.IsWindowVisible
284
+
285
+ WM_CLOSE = 0x0010
286
+
287
+ # Match window title: our callback page sets title to "Close manually" or
288
+ # "Authorization Successful · Miravia Skill CLI"
289
+ match_keywords = ("Miravia Skill CLI", "Close manually")
290
+
291
+ @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
292
+ def enum_cb(hwnd, _lparam):
293
+ if not IsWindowVisible(hwnd):
294
+ return True
295
+ buf = ctypes.create_unicode_buffer(512)
296
+ GetWindowTextW(hwnd, buf, 512)
297
+ title = buf.value
298
+ if any(kw in title for kw in match_keywords):
299
+ PostMessageW(hwnd, WM_CLOSE, 0, 0)
300
+ logger.info("WM_CLOSE sent to window: title=%s", title[:60])
301
+ return False # stop enumeration
302
+ return True
303
+
304
+ EnumWindows(enum_cb, 0)
305
+ except Exception as e: # noqa: BLE001
306
+ logger.debug("Windows close failed: %s", e)
307
+
308
+
309
+ def _close_tab_linux(url_fragment: str) -> None:
310
+ """Linux: Use xdotool to find the window by name and send Ctrl+W."""
311
+ match_keywords = ("Miravia Skill CLI", "Close manually")
312
+ for kw in match_keywords:
313
+ try:
314
+ result = subprocess.run(
315
+ ["xdotool", "search", "--name", kw],
316
+ capture_output=True, text=True, timeout=3,
317
+ )
318
+ window_ids = result.stdout.strip().split()
319
+ if window_ids:
320
+ wid = window_ids[0]
321
+ subprocess.run(
322
+ ["xdotool", "key", "--window", wid, "ctrl+w"],
323
+ capture_output=True, timeout=3,
324
+ )
325
+ logger.info("xdotool Ctrl+W sent to window: id=%s keyword=%s", wid, kw)
326
+ return
327
+ except FileNotFoundError:
328
+ logger.debug("xdotool not installed, cannot close browser tab on Linux")
329
+ return
330
+ except Exception as e: # noqa: BLE001
331
+ logger.debug("Linux close failed for keyword '%s': %s", kw, e)
332
+
333
+
334
+ # ------------- credentials I/O -------------
335
+
336
+ # Fields stored in credentials.json (reduced to 5 essential fields)
337
+ CRED_KEYS = (
338
+ "app_key",
339
+ "app_secret",
340
+ "access_token",
341
+ "token_expires_at",
342
+ "last_login_at",
343
+ )
344
+
345
+
346
+ def load_creds(path: str = config.CRED_FILE) -> Dict[str, str]:
347
+ """Read credentials.json, auto-decrypt sensitive fields with ``enc:v1:`` prefix.
348
+
349
+ If sensitive fields are plaintext (legacy), treat as invalid, return empty dict
350
+ and delete old file to force re-authorization.
351
+ """
352
+ if not os.path.exists(path):
353
+ return {}
354
+ try:
355
+ with open(path, "r", encoding="utf-8") as f:
356
+ raw = json.load(f)
357
+ except (OSError, json.JSONDecodeError):
358
+ return {}
359
+ # Convert JSON field names to env-var style keys (for internal consistency)
360
+ mapped = {}
361
+ key_map_reverse = {
362
+ "app_key": "MIRAVIA_APP_KEY",
363
+ "app_secret": "MIRAVIA_APP_SECRET",
364
+ "access_token": "MIRAVIA_ACCESS_TOKEN",
365
+ "token_expires_at": "MIRAVIA_TOKEN_EXPIRES_AT",
366
+ "last_login_at": "MIRAVIA_LAST_LOGIN_AT",
367
+ }
368
+ for json_key, env_key in key_map_reverse.items():
369
+ v = raw.get(json_key)
370
+ if v is not None:
371
+ mapped[env_key] = str(v)
372
+ try:
373
+ return decrypt_creds(mapped)
374
+ except CredCryptoError as e:
375
+ logger.error("load_creds: %s", e)
376
+ # Plaintext or corrupted credential file: delete and force re-authorization
377
+ try:
378
+ os.remove(path)
379
+ logger.info("load_creds: removed invalid cred file: %s", path)
380
+ except OSError:
381
+ pass
382
+ return {}
383
+
384
+
385
+ def save_creds(creds: Dict[str, str], path: str = config.CRED_FILE) -> None:
386
+ """Encrypt sensitive fields (APP_KEY/APP_SECRET/ACCESS_TOKEN) with AES-256-GCM
387
+ and persist as JSON, satisfying PRD §4.5.a (App Token stored encrypted, never plaintext).
388
+
389
+ Only stores 5 essential fields: app_key, app_secret, access_token, token_expires_at, last_login_at.
390
+ Aborts and raises if .keypair.json is missing or encryption fails.
391
+ """
392
+ os.makedirs(os.path.dirname(path), exist_ok=True)
393
+ try:
394
+ encrypted = encrypt_creds(creds)
395
+ except CredCryptoError as e:
396
+ logger.error("save_creds: encrypt failed: %s", e)
397
+ raise
398
+ # Build JSON object (only store essential fields, skip empty values)
399
+ env_to_json = {
400
+ "MIRAVIA_APP_KEY": "app_key",
401
+ "MIRAVIA_APP_SECRET": "app_secret",
402
+ "MIRAVIA_ACCESS_TOKEN": "access_token",
403
+ "MIRAVIA_TOKEN_EXPIRES_AT": "token_expires_at",
404
+ "MIRAVIA_LAST_LOGIN_AT": "last_login_at",
405
+ }
406
+ json_data = {
407
+ "_comment": (
408
+ "Miravia Skill Hub credentials (auto-generated, DO NOT commit). "
409
+ "Sensitive fields are AES-256-GCM encrypted (enc:v1:...). "
410
+ "Key is derived from .keypair.json via HKDF-SHA256 + machine fingerprint."
411
+ ),
412
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z") or time.strftime("%Y-%m-%dT%H:%M:%S"),
413
+ }
414
+ for env_key, json_key in env_to_json.items():
415
+ v = encrypted.get(env_key, "")
416
+ if v: # skip empty values
417
+ json_data[json_key] = v
418
+ tmp = path + ".tmp"
419
+ with open(tmp, "w", encoding="utf-8") as f:
420
+ json.dump(json_data, f, indent=2, ensure_ascii=False)
421
+ f.write("\n")
422
+ os.replace(tmp, path)
423
+ try:
424
+ os.chmod(path, 0o600)
425
+ except OSError:
426
+ pass
427
+ enc_count = sum(1 for k in SENSITIVE_KEYS if is_encrypted(encrypted.get(k, "")))
428
+ try:
429
+ st = os.stat(path)
430
+ logger.info(
431
+ "creds saved: path=%s size=%dB mtime=%.3f encrypted_fields=%d/%d",
432
+ path, st.st_size, st.st_mtime, enc_count, len(SENSITIVE_KEYS),
433
+ )
434
+ except OSError:
435
+ logger.info(
436
+ "creds saved: path=%s (stat failed) encrypted_fields=%d/%d",
437
+ path, enc_count, len(SENSITIVE_KEYS),
438
+ )
439
+
440
+
441
+ def _expires_at(now: float, expires_in) -> str:
442
+ """Normalize backend-returned expiry time to YYYY-MM-DD HH:MM format.
443
+
444
+ Tolerates three input types:
445
+ - Relative seconds (OAuth standard ``expires_in``) -> now + v
446
+ - Absolute seconds timestamp (10 digits) -> v as-is
447
+ - Absolute milliseconds timestamp (13 digits, common in Miravia) -> v // 1000
448
+ """
449
+ if expires_in in (None, ""):
450
+ return ""
451
+ try:
452
+ v = int(expires_in)
453
+ except (TypeError, ValueError):
454
+ return ""
455
+ # Threshold: > 1e12 = milliseconds; 1e10 ~ 1e12 = absolute seconds; otherwise = relative seconds.
456
+ if v >= 10**12:
457
+ ts = v // 1000
458
+ elif v >= 10**10:
459
+ ts = v
460
+ else:
461
+ ts = int(now + v)
462
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
463
+
464
+ def _payload_to_creds(payload: Dict, now: Optional[float] = None) -> Dict[str, str]:
465
+ """Convert decrypted Skill Hub plaintext payload to credentials dict.
466
+
467
+ Handles both camelCase and snake_case keys; transparently unwraps ``data`` wrapper if present.
468
+ Only retains 5 essential fields.
469
+ """
470
+ now = now if now is not None else time.time()
471
+ body = payload.get("data") if isinstance(payload.get("data"), dict) else payload
472
+
473
+ def pick(*keys, default=""):
474
+ for k in keys:
475
+ v = body.get(k)
476
+ if v not in (None, ""):
477
+ return v
478
+ return default
479
+
480
+ expires_in = pick("expiresIn", "expires_in")
481
+
482
+ return {
483
+ "MIRAVIA_APP_KEY": str(pick("appKey", "app_key") or ""),
484
+ "MIRAVIA_APP_SECRET": str(pick("appSecret", "app_secret", "secret") or ""),
485
+ "MIRAVIA_ACCESS_TOKEN": str(pick("accessToken", "access_token") or ""),
486
+ "MIRAVIA_TOKEN_EXPIRES_AT": _expires_at(now, expires_in),
487
+ "MIRAVIA_LAST_LOGIN_AT": time.strftime("%Y-%m-%dT%H:%M:%S"),
488
+ }
489
+
490
+
491
+ def _decrypt_cipher_to_creds(
492
+ cipher: str, private_key_pem: str, sign_method: str
493
+ ) -> Tuple[Optional[Dict[str, str]], str]:
494
+ """Unified RSA chunked decryption + payload parsing entry point.
495
+
496
+ Returns:
497
+ (creds_dict, "") on success; (None, error_message) on failure.
498
+ Shared by _try_preflight_decrypt and cmd_login fallback,
499
+ eliminating duplicated decrypt-convert-error-handling logic.
500
+ """
501
+ try:
502
+ plaintext = decrypt_payload(cipher, private_key_pem, sign_method)
503
+ except SkillAuthDecryptError as e:
504
+ return None, str(e)
505
+ creds = _payload_to_creds(plaintext)
506
+ return creds, ""
507
+
508
+
509
+ def _fmt_expires(expires_str: str) -> str:
510
+ """Format expiry time string (YYYY-MM-DD HH:MM) with remaining time description."""
511
+ if not expires_str:
512
+ return "<unknown>"
513
+ # Try parsing YYYY-MM-DD HH:MM format
514
+ try:
515
+ import datetime
516
+ dt = datetime.datetime.strptime(expires_str.strip(), "%Y-%m-%d %H:%M")
517
+ ts = int(dt.timestamp())
518
+ except (ValueError, TypeError):
519
+ # Compatible with legacy epoch seconds format
520
+ try:
521
+ ts = int(expires_str)
522
+ except (ValueError, TypeError):
523
+ return expires_str
524
+ delta = ts - int(time.time())
525
+ iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
526
+ if delta <= 0:
527
+ return f"{iso} (EXPIRED {-delta}s ago)"
528
+ days, rem = divmod(delta, 86400)
529
+ hours, rem = divmod(rem, 3600)
530
+ mins, _ = divmod(rem, 60)
531
+ return f"{iso} (in {days}d {hours}h {mins}m)"
532
+
533
+
534
+ # ------------- i18n for callback page -------------
535
+
536
+ # Supported languages for the callback result page.
537
+ # Frontend passes ``lang`` parameter in the redirect URL;
538
+ # falls back to English if missing or unsupported.
539
+ _SUPPORTED_LANGS = ("en", "es", "it")
540
+ _DEFAULT_LANG = "en"
541
+
542
+ _I18N_TEXTS: Dict[str, Dict[str, str]] = {
543
+ "en": {
544
+ "html_lang": "en",
545
+ "page_title_header": "Skill Authorization",
546
+ "success_title": "Authorization Successful",
547
+ "success_sub": (
548
+ "The skill has been successfully authorized and "
549
+ "is active in your account."
550
+ ),
551
+ "token_valid_30days": "Token validity: 30 days",
552
+ "token_expired": "Token expired, please re-authorize",
553
+ "token_valid_until": "Valid until {iso} ({days} days)",
554
+ "auto_close_prefix": "This page will close automatically in ",
555
+ "auto_close_suffix": " s.",
556
+ "manual_close_hint": (
557
+ "If your browser blocks automatic closing, "
558
+ "press <kbd>⌘</kbd>+<kbd>W</kbd>."
559
+ ),
560
+ "manual_close_title": "Close manually · ⌘+W",
561
+ "fail_title": "Authorization Not Completed",
562
+ "decrypt_fail_sub": (
563
+ "Unable to decrypt the payload with the local private key. "
564
+ "Verify that .keypair.json is paired with the server's public key."
565
+ ),
566
+ "no_payload_sub": (
567
+ "The encrypted payload was not received from the authorization page."
568
+ ),
569
+ "error_footer": (
570
+ "Check the terminal for error details, or re-run "
571
+ "<kbd>python3 scripts/auth_cli.py login</kbd>."
572
+ ),
573
+ },
574
+ "es": {
575
+ "html_lang": "es",
576
+ "page_title_header": "Skill Authorization",
577
+ "success_title": "Autorización exitosa",
578
+ "success_sub": (
579
+ "La skill ha sido autorizada correctamente y "
580
+ "está activa en tu cuenta."
581
+ ),
582
+ "token_valid_30days": "Validez del token: 30 días",
583
+ "token_expired": "Token caducado, vuelve a autorizar",
584
+ "token_valid_until": "Válido hasta {iso} ({days} días)",
585
+ "auto_close_prefix": "Esta página se cerrará automáticamente en ",
586
+ "auto_close_suffix": " s.",
587
+ "manual_close_hint": (
588
+ "Si tu navegador bloquea el cierre automático, "
589
+ "pulsa <kbd>⌘</kbd>+<kbd>W</kbd>."
590
+ ),
591
+ "manual_close_title": "Cerrar manualmente · ⌘+W",
592
+ "fail_title": "Autorización no completada",
593
+ "decrypt_fail_sub": (
594
+ "No se ha podido descifrar el payload con la clave privada local. "
595
+ "Verifica que .keypair.json esté emparejado con la clave pública del servidor."
596
+ ),
597
+ "no_payload_sub": (
598
+ "No se ha recibido el payload cifrado de la página de autorización."
599
+ ),
600
+ "error_footer": (
601
+ "Consulta la terminal para ver el detalle del error, "
602
+ "o vuelve a ejecutar <kbd>python3 scripts/auth_cli.py login</kbd>."
603
+ ),
604
+ },
605
+ "it": {
606
+ "html_lang": "it",
607
+ "page_title_header": "Skill Authorization",
608
+ "success_title": "Autorizzazione riuscita",
609
+ "success_sub": (
610
+ "La skill è stata autorizzata correttamente ed "
611
+ "è attiva nel tuo account."
612
+ ),
613
+ "token_valid_30days": "Validità del token: 30 giorni",
614
+ "token_expired": "Token scaduto, riautorizza",
615
+ "token_valid_until": "Valido fino al {iso} ({days} giorni)",
616
+ "auto_close_prefix": "Questa pagina si chiuderà automaticamente in ",
617
+ "auto_close_suffix": " s.",
618
+ "manual_close_hint": (
619
+ "Se il tuo browser blocca la chiusura automatica, "
620
+ "premi <kbd>⌘</kbd>+<kbd>W</kbd>."
621
+ ),
622
+ "manual_close_title": "Chiudi manualmente · ⌘+W",
623
+ "fail_title": "Autorizzazione non completata",
624
+ "decrypt_fail_sub": (
625
+ "Impossibile decifrare il payload con la chiave privata locale. "
626
+ "Verifica che .keypair.json sia abbinato alla chiave pubblica del server."
627
+ ),
628
+ "no_payload_sub": (
629
+ "Il payload cifrato non è stato ricevuto dalla pagina di autorizzazione."
630
+ ),
631
+ "error_footer": (
632
+ "Controlla il terminale per i dettagli dell'errore "
633
+ "o esegui nuovamente <kbd>python3 scripts/auth_cli.py login</kbd>."
634
+ ),
635
+ },
636
+ }
637
+
638
+
639
+ def _resolve_lang(qs: Dict[str, str]) -> str:
640
+ """Extract and validate language from callback query string.
641
+
642
+ Frontend passes ``lang`` parameter in locale format (e.g. ``it_IT``,
643
+ ``es_ES``, ``en_US``) or short form (``en``, ``es``, ``it``) in the
644
+ redirect URL. Extracts the language prefix before ``_`` or ``-``.
645
+ Falls back to English if missing or unsupported.
646
+ """
647
+ raw = (qs.get("lang") or "").lower().strip()
648
+ # Handle locale formats: it_IT -> it, es_ES -> es, en-US -> en
649
+ lang = raw.split("_")[0].split("-")[0]
650
+ if lang not in _SUPPORTED_LANGS:
651
+ return _DEFAULT_LANG
652
+ return lang
653
+
654
+
655
+ # ------------- callback HTTP server --------------
656
+
657
+
658
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
659
+ """Only callback form: GET /callback?encryptedPayload=...&lang=...
660
+
661
+ After the auth page receives ``encryptedPayload`` from backend, it redirects
662
+ browser 302 to local redirect_uri; CLI does not accept POST/JSON.
663
+ Parameter name matches backend `data.encryptedPayload` exactly, no aliases.
664
+ Optional ``lang`` parameter (en/es/it) controls callback page language;
665
+ defaults to English if missing or unsupported.
666
+ """
667
+
668
+ server_version = "MiraviaSkillCLI/1.0"
669
+
670
+ def _store(self, qs: Dict[str, str],
671
+ creds: Optional[Dict[str, str]] = None,
672
+ decrypt_err: str = "") -> None:
673
+ # Only record first valid result; avoid favicon.ico noise overwriting
674
+ if getattr(self.server, "callback_result", None):
675
+ return
676
+ self.server.callback_result = qs # type: ignore[attr-defined]
677
+ if creds:
678
+ self.server.preflight_creds = creds # type: ignore[attr-defined]
679
+ if decrypt_err:
680
+ self.server.preflight_decrypt_error = decrypt_err # type: ignore[attr-defined]
681
+ cipher = qs.get("encryptedPayload") or ""
682
+ logger.info(
683
+ "callback stored: keys=%s encryptedPayload.len=%d error=%r preflight=%s",
684
+ sorted(qs.keys()), len(cipher), qs.get("error") or "",
685
+ "ok" if creds else ("err:" + decrypt_err if decrypt_err else "skip"),
686
+ )
687
+
688
+ def _try_preflight_decrypt(self, cipher: str) -> Tuple[Optional[Dict[str, str]], str]:
689
+ """Perform RSA segmented decryption within callback thread, converting ``encryptedPayload``
690
+ to credential fields so ``_render_html`` can display real token expiry time,
691
+ and avoid redundant decryption in main thread.
692
+
693
+ - If server has no ``.skill_keypair`` (debug mode / no keypair) -> skip;
694
+ - If cipher is empty (error callback) -> skip;
695
+ - If same callback entered multiple times (browser favicon etc.) -> reuse cached result.
696
+ """
697
+ kp = getattr(self.server, "skill_keypair", None)
698
+ if not kp or not cipher:
699
+ return None, ""
700
+ cached = getattr(self.server, "preflight_creds", None)
701
+ if cached:
702
+ return cached, ""
703
+ started = time.time()
704
+ creds, err = _decrypt_cipher_to_creds(cipher, kp["private_key_pem"], kp["sign_method"])
705
+ if err:
706
+ logger.error("preflight decrypt failed: %s", err)
707
+ return None, err
708
+ logger.info(
709
+ "preflight decrypt done in %.3fs: expires_at=%s",
710
+ time.time() - started, creds.get("MIRAVIA_TOKEN_EXPIRES_AT", ""),
711
+ )
712
+ return creds, ""
713
+
714
+ @staticmethod
715
+ def _fmt_validity(expires_at: str, lang: str = _DEFAULT_LANG) -> str:
716
+ """Render token validity prompt text with real expiry time.
717
+
718
+ Parses ``YYYY-MM-DD HH:MM`` format (produced by _expires_at()).
719
+ Falls back to fixed 30-day text on failure, ensuring callback page is not blocked.
720
+ """
721
+ t = _I18N_TEXTS.get(lang, _I18N_TEXTS[_DEFAULT_LANG])
722
+ if not expires_at:
723
+ return t["token_valid_30days"]
724
+ # Parse "YYYY-MM-DD HH:MM" format (standard output of _expires_at)
725
+ import datetime as _dt
726
+ try:
727
+ dt = _dt.datetime.strptime(expires_at.strip(), "%Y-%m-%d %H:%M")
728
+ ts = int(dt.timestamp())
729
+ except (ValueError, TypeError):
730
+ # Fallback: try legacy epoch seconds format
731
+ try:
732
+ ts = int(expires_at)
733
+ except (TypeError, ValueError):
734
+ return t["token_valid_30days"]
735
+ delta = ts - int(time.time())
736
+ if delta <= 0:
737
+ return t["token_expired"]
738
+ iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
739
+ days = delta // 86400
740
+ return t["token_valid_until"].format(iso=iso, days=days)
741
+
742
+ # Response page uses inline CSS, auto-adapts to light/dark mode, no external resources.
743
+ # Design: minimal white card + top 'Skill Authorization' title + centered status +
744
+ # yellow token validity notice box.
745
+ _BASE_CSS = """
746
+ *,*::before,*::after { box-sizing: border-box; }
747
+ html, body { height: 100%; margin: 0; }
748
+ body {
749
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
750
+ "Helvetica Neue", "Segoe UI", Roboto, Arial, sans-serif;
751
+ background: #f5f6f8;
752
+ color: #1f2937;
753
+ padding: 40px 24px;
754
+ min-height: 100%;
755
+ }
756
+ .layout { max-width: 960px; margin: 0 auto; }
757
+ .page-title {
758
+ font-size: 28px; font-weight: 700; color: #111827;
759
+ margin: 0 0 28px; letter-spacing: -.01em;
760
+ }
761
+ .card {
762
+ background: #ffffff;
763
+ border: 1px solid #e5e7eb;
764
+ border-radius: 16px;
765
+ padding: 64px 40px 56px;
766
+ text-align: center;
767
+ }
768
+ .icon {
769
+ width: 72px; height: 72px; margin: 0 auto 28px;
770
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
771
+ color: #fff; font-size: 40px; line-height: 1; font-weight: 700;
772
+ animation: pop .45s cubic-bezier(.2,.9,.3,1.4) both;
773
+ }
774
+ .icon.ok { background: #22c55e; box-shadow: 0 8px 20px -6px #22c55e55; }
775
+ .icon.err { background: #ef4444; box-shadow: 0 8px 20px -6px #ef444455; }
776
+ h1 {
777
+ margin: 0 0 14px; font-size: 26px; font-weight: 700;
778
+ color: #111827; letter-spacing: -.01em;
779
+ }
780
+ p.sub {
781
+ margin: 0 auto 28px; color: #6b7280; font-size: 15px; line-height: 1.6;
782
+ max-width: 460px;
783
+ }
784
+ .token-info {
785
+ display: inline-flex; align-items: center; gap: 8px;
786
+ padding: 10px 18px; border-radius: 10px;
787
+ background: #fef3c7; border: 1px solid #fde68a;
788
+ color: #92400e; font-size: 14px; font-weight: 500;
789
+ }
790
+ .token-info .info-icon {
791
+ width: 18px; height: 18px; border-radius: 50%;
792
+ border: 1.5px solid #b45309; color: #b45309;
793
+ display: inline-flex; align-items: center; justify-content: center;
794
+ font-size: 12px; font-weight: 700;
795
+ font-family: Georgia, "Times New Roman", serif;
796
+ }
797
+ .error-detail {
798
+ margin: 0 auto; padding: 12px 16px; max-width: 460px;
799
+ background: #fef2f2; border: 1px solid #fecaca;
800
+ border-radius: 10px; color: #991b1b; font-size: 13px;
801
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
802
+ word-break: break-all; text-align: left;
803
+ }
804
+ .footer { margin-top: 28px; font-size: 13px; color: #9ca3af; }
805
+ .hint { color: #9ca3af; font-size: 12px; }
806
+ kbd {
807
+ background:#f1f5f9; border:1px solid #e2e8f0; border-bottom-width:2px;
808
+ border-radius:6px; padding:1px 6px; font-size:11px; color:#334155;
809
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
810
+ }
811
+ @keyframes pop { 0%{transform:scale(.4);opacity:0} 100%{transform:scale(1);opacity:1} }
812
+ @media (prefers-color-scheme: dark) {
813
+ body { background: #0b1220; color: #e5e7eb; }
814
+ .page-title { color: #f3f4f6; }
815
+ .card { background: #0f172a; border-color: #1f2937; }
816
+ h1 { color: #f3f4f6; }
817
+ p.sub, .footer, .hint { color: #94a3b8; }
818
+ .token-info { background: #422006; border-color: #78350f; color: #fbbf24; }
819
+ .token-info .info-icon { border-color: #fbbf24; color: #fbbf24; }
820
+ .error-detail { background: #450a0a; border-color: #7f1d1d; color: #fecaca; }
821
+ kbd { background:#1f2937; border-color:#374151; color:#e5e7eb; }
822
+ }
823
+ """
824
+
825
+ def _render_html(self, qs: Dict[str, str],
826
+ creds: Optional[Dict[str, str]] = None,
827
+ decrypt_err: str = "") -> bytes:
828
+ cipher = qs.get("encryptedPayload") or ""
829
+ err = qs.get("error") or qs.get("error_description") or ""
830
+ # Treat decryption failure as error page to avoid misleading "green check + 30 days" prompt
831
+ success = bool(cipher) and not err and not decrypt_err
832
+
833
+ lang = _resolve_lang(qs)
834
+ t = _I18N_TEXTS.get(lang, _I18N_TEXTS[_DEFAULT_LANG])
835
+
836
+ if success:
837
+ icon_html = '<div class="icon ok">✓</div>'
838
+ title = t["success_title"]
839
+ sub = t["success_sub"]
840
+ validity_text = self._fmt_validity(
841
+ (creds or {}).get("MIRAVIA_TOKEN_EXPIRES_AT", ""),
842
+ lang,
843
+ )
844
+ info_html = (
845
+ '<div class="token-info">'
846
+ '<span class="info-icon">i</span>'
847
+ f'{html.escape(validity_text)}'
848
+ '</div>'
849
+ )
850
+ footer = (
851
+ '<p class="footer">' + t["auto_close_prefix"] +
852
+ '<span id="cd" style="font-weight:600;color:#10b981">10</span>' +
853
+ t["auto_close_suffix"] + '<br>'
854
+ '<span class="hint" id="manual-tip" style="display:none">'
855
+ + t["manual_close_hint"] +
856
+ '</span></p>'
857
+ )
858
+ script = (
859
+ '<script>(function(){'
860
+ 'var s=document.getElementById("cd");'
861
+ 'var tip=document.getElementById("manual-tip");'
862
+ 'var n=10;'
863
+ 'function bye(){'
864
+ 'try{window.open("","_self","").close();}catch(e){}'
865
+ 'try{window.close();}catch(e){}'
866
+ 'setTimeout(function(){'
867
+ 'if(tip){tip.style.display="inline";}'
868
+ f'document.title={json.dumps(t["manual_close_title"])};'
869
+ '},150);'
870
+ '}'
871
+ 'var tid=setInterval(function(){'
872
+ 'n-=1;'
873
+ 'if(s){s.textContent=n;}'
874
+ 'if(n<=0){clearInterval(tid);bye();}'
875
+ '},1000);'
876
+ '})();</script>'
877
+ )
878
+ else:
879
+ icon_html = '<div class="icon err">✕</div>'
880
+ title = t["fail_title"]
881
+ if decrypt_err:
882
+ reason = f"DECRYPT_FAILED: {decrypt_err}"
883
+ sub = t["decrypt_fail_sub"]
884
+ else:
885
+ reason = err or "no encryptedPayload returned"
886
+ sub = t["no_payload_sub"]
887
+ info_html = (
888
+ f'<div class="error-detail">{html.escape(reason)}</div>'
889
+ )
890
+ footer = (
891
+ '<p class="footer">' + t["error_footer"] + '</p>'
892
+ )
893
+ script = ""
894
+
895
+ body = (
896
+ f'<!doctype html><html lang="{t["html_lang"]}"><head>'
897
+ '<meta charset="utf-8">'
898
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
899
+ f'<title>{title} · Miravia Skill CLI</title>'
900
+ f'<style>{self._BASE_CSS}</style>'
901
+ '</head><body>'
902
+ '<div class="layout">'
903
+ f'<header class="page-title">{t["page_title_header"]}</header>'
904
+ '<main class="card" role="status" aria-live="polite">'
905
+ f'{icon_html}'
906
+ f'<h1>{title}</h1>'
907
+ f'<p class="sub">{sub}</p>'
908
+ f'{info_html}'
909
+ f'{footer}'
910
+ '</main>'
911
+ '</div>'
912
+ f'{script}'
913
+ '</body></html>'
914
+ )
915
+ return body.encode("utf-8")
916
+
917
+ def _validate_state(self, qs: Dict[str, str]) -> Optional[str]:
918
+ """Validate the CSRF state parameter.
919
+
920
+ Rules:
921
+ - The authorization URL must include a state parameter
922
+ - On callback, verify state matches; reject if mismatched
923
+ - State can only be used once (one-time token)
924
+ - State is destroyed immediately after use
925
+
926
+ Returns:
927
+ None if validation passes; otherwise an error description string.
928
+ """
929
+ expected = getattr(self.server, "csrf_state", None)
930
+ if not expected:
931
+ # No state set (--encrypted-payload mode), skip validation
932
+ return None
933
+ received = qs.get("state", "")
934
+ if not received:
935
+ logger.warning("CSRF: callback missing state parameter")
936
+ return "missing state parameter"
937
+ if not secrets.compare_digest(received, expected):
938
+ logger.warning(
939
+ "CSRF: state mismatch (expected=%s...%s received=%s...%s)",
940
+ expected[:4], expected[-4:], received[:4], received[-4:],
941
+ )
942
+ return "state mismatch (possible CSRF attack)"
943
+ # State validated -> destroy immediately, ensure single-use
944
+ self.server.csrf_state = None # type: ignore[attr-defined]
945
+ logger.info("CSRF: state validated and destroyed")
946
+ return None
947
+
948
+ def do_GET(self): # noqa: N802
949
+ # Force HTTP/1.0 response + Connection: close to prevent browser keep-alive
950
+ # from blocking wfile.close() / serve_forever with TCP idle timeout.
951
+ # (Effect: browser sends FIN immediately after response, handle_one_request exits.)
952
+ self.close_connection = True
953
+ parsed = urllib.parse.urlparse(self.path)
954
+ # Log the moment browser actually issues GET (with client address and User-Agent summary)
955
+ ua = self.headers.get("User-Agent", "") if self.headers else ""
956
+ logger.info(
957
+ "callback GET: path=%s query.len=%d client=%s ua=%s",
958
+ parsed.path, len(parsed.query or ""),
959
+ getattr(self, "client_address", ("", 0))[0], ua[:80],
960
+ )
961
+ if parsed.path not in ("/callback", "/"):
962
+ self.send_response(404)
963
+ self.send_header("Connection", "close")
964
+ self.end_headers()
965
+ return
966
+ qs = dict(urllib.parse.parse_qsl(parsed.query))
967
+
968
+ # ── CSRF state validation ──
969
+ state_err = self._validate_state(qs)
970
+ if state_err:
971
+ # State validation failed -> 403 reject, no decrypt, no persist
972
+ qs_err = {"error": "csrf_rejected", "error_description": state_err}
973
+ # Preserve lang so the error page renders in the correct language
974
+ if qs.get("lang"):
975
+ qs_err["lang"] = qs["lang"]
976
+ self._store(qs_err)
977
+ body = self._render_html(qs_err)
978
+ self.send_response(403)
979
+ self.send_header("Content-Type", "text/html; charset=utf-8")
980
+ self.send_header("Content-Length", str(len(body)))
981
+ self.send_header("Connection", "close")
982
+ self.end_headers()
983
+ self.wfile.write(body)
984
+ try:
985
+ self.wfile.flush()
986
+ except OSError:
987
+ pass
988
+ logger.warning("callback rejected: %s", state_err)
989
+ return
990
+
991
+ # Perform RSA segmented decryption in callback thread: need real expires_at
992
+ # to replace hardcoded '30 dias' on success page with dynamic expiry time,
993
+ # and let main thread cmd_login skip redundant decryption.
994
+ cipher = qs.get("encryptedPayload") or ""
995
+ creds, decrypt_err = self._try_preflight_decrypt(cipher)
996
+ self._store(qs, creds=creds, decrypt_err=decrypt_err)
997
+ body = self._render_html(qs, creds=creds, decrypt_err=decrypt_err)
998
+ self.send_response(200)
999
+ self.send_header("Content-Type", "text/html; charset=utf-8")
1000
+ self.send_header("Content-Length", str(len(body)))
1001
+ self.send_header("Connection", "close")
1002
+ self.end_headers()
1003
+ self.wfile.write(body)
1004
+ try:
1005
+ self.wfile.flush()
1006
+ except OSError:
1007
+ pass
1008
+ logger.info(
1009
+ "callback GET done: path=%s status=%d body.len=%d",
1010
+ parsed.path, 200, len(body),
1011
+ )
1012
+
1013
+ def log_message(self, fmt, *args): # silent
1014
+ return
1015
+
1016
+
1017
+ def _generate_csrf_state() -> str:
1018
+ """Generate CSRF state: 32-byte URL-safe random token (single-use)."""
1019
+ return secrets.token_urlsafe(32)
1020
+
1021
+
1022
+ def _start_callback_server(host: str, port: int,
1023
+ keypair: Optional[Dict] = None,
1024
+ csrf_state: Optional[str] = None) -> http.server.HTTPServer:
1025
+ """Start callback HTTP server first (serve_forever in separate thread),
1026
+ then let caller open browser, avoiding callback arriving before server bind race.
1027
+
1028
+ Uses ``ThreadingHTTPServer`` + ``daemon_threads=True`` to prevent request
1029
+ finish() phase (browser keep-alive idle, wfile.close waiting for FIN etc.)
1030
+ from blocking main thread's ``server.shutdown()``.
1031
+
1032
+ ``keypair`` if provided, is passed to handler for RSA segmented decryption
1033
+ in callback thread (success page renders real token expiry), main thread skips re-decrypt.
1034
+
1035
+ ``csrf_state`` if provided, callback handler validates query string state parameter
1036
+ matches this value; rejects with 403 if mismatch (CSRF protection).
1037
+ """
1038
+ logger.info("callback server starting: host=%s port=%d csrf_state=%s",
1039
+ host, port, bool(csrf_state))
1040
+ server = http.server.ThreadingHTTPServer((host, port), _CallbackHandler)
1041
+ server.callback_result = None # type: ignore[attr-defined]
1042
+ # CSRF state: destroyed immediately after callback handler validation (single-use token)
1043
+ server.csrf_state = csrf_state # type: ignore[attr-defined]
1044
+ # Decryption moved earlier: inject .keypair.json into server so callback thread
1045
+ # performs RSA segmented decryption in do_GET, HTML response can show real expiry time.
1046
+ server.skill_keypair = keypair # type: ignore[attr-defined]
1047
+ server.preflight_creds = None # type: ignore[attr-defined]
1048
+ server.preflight_decrypt_error = "" # type: ignore[attr-defined]
1049
+ server.timeout = 1.0
1050
+ server.daemon_threads = True # request sub-threads exit with main process, don't block shutdown
1051
+ t = threading.Thread(target=server.serve_forever, daemon=True)
1052
+ t.start()
1053
+ server._serve_thread = t # type: ignore[attr-defined]
1054
+ logger.info(
1055
+ "callback server bound & serve_forever thread started (Threading=%s)",
1056
+ type(server).__name__,
1057
+ )
1058
+ return server
1059
+
1060
+
1061
+ def _wait_callback(server: http.server.HTTPServer,
1062
+ timeout: float = 300.0) -> Dict[str, str]:
1063
+ deadline = time.time() + timeout
1064
+ started = time.time()
1065
+ logger.info("wait_callback: begin timeout=%.1fs", timeout)
1066
+ try:
1067
+ while time.time() < deadline:
1068
+ if server.callback_result: # type: ignore[attr-defined]
1069
+ logger.info(
1070
+ "wait_callback: result picked up after %.3fs",
1071
+ time.time() - started,
1072
+ )
1073
+ return server.callback_result # type: ignore[attr-defined]
1074
+ time.sleep(0.05) # 200ms -> 50ms, reduce trailing poll jitter
1075
+ raise TimeoutError(f"Authorization callback timed out ({timeout:.0f}s)")
1076
+ finally:
1077
+ shutdown_started = time.time()
1078
+ server.shutdown()
1079
+ server.server_close()
1080
+ logger.info(
1081
+ "wait_callback: server shutdown closed in %.3fs",
1082
+ time.time() - shutdown_started,
1083
+ )
1084
+
1085
+
1086
+ def _check_port_free(host: str, port: int) -> Tuple[bool, str]:
1087
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1088
+ try:
1089
+ s.bind((host, port))
1090
+ return True, ""
1091
+ except OSError as e:
1092
+ return False, str(e)
1093
+ finally:
1094
+ s.close()
1095
+
1096
+
1097
+ def _wait_until_listening(host: str, port: int,
1098
+ timeout: float = 3.0) -> bool:
1099
+ """Probe local server is accepting before opening browser.
1100
+ Prevents connection refused when browser callback arrives immediately after webbrowser.open.
1101
+ """
1102
+ started = time.time()
1103
+ deadline = started + timeout
1104
+ while time.time() < deadline:
1105
+ try:
1106
+ with socket.create_connection((host, port), timeout=0.5):
1107
+ logger.info(
1108
+ "listening probe ok: host=%s port=%d elapsed=%.3fs",
1109
+ host, port, time.time() - started,
1110
+ )
1111
+ return True
1112
+ except OSError:
1113
+ time.sleep(0.05)
1114
+ logger.warning(
1115
+ "listening probe timed out after %.3fs (host=%s port=%d)",
1116
+ time.time() - started, host, port,
1117
+ )
1118
+ return False
1119
+
1120
+
1121
+ def _build_hub_authorize_url(skill_code: str, redirect_uri: str,
1122
+ state: Optional[str] = None) -> str:
1123
+ """Build authorization page redirect URL.
1124
+
1125
+ \u26a0\ufe0f Frontend has strict requirements for redirect_uri format:
1126
+ - state must be appended as **unencoded** ?state=xxx after the encoded redirect_uri;
1127
+ - state cannot be a top-level param of auth URL (&state=xxx), or frontend reports CSRF error;
1128
+ - ?state=xxx inside redirect_uri must NOT be percent-encoded (e.g. %3Fstate%3D),
1129
+ otherwise frontend decodeURIComponent creates nested params causing 302 redirect anomaly.
1130
+
1131
+ Final URL example:
1132
+ https://pre-sellercenter.miravia.es/apps/seller/skill/auth
1133
+ ?skill_code=miravia_order_reports
1134
+ &redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback?state=xxx
1135
+
1136
+ Frontend parses redirect_uri with new URL(redirectUri), then appends encryptedPayload:
1137
+ → http://127.0.0.1:8765/callback?state=xxx&encryptedPayload=yyy
1138
+ """
1139
+ # First percent-encode base redirect_uri (encode special chars like : / ? etc.)
1140
+ encoded_redirect = urllib.parse.quote(redirect_uri, safe='')
1141
+ if state:
1142
+ # state appended as ?state=xxx directly after encoded redirect_uri,
1143
+ # ? and = kept unencoded -- this is the only valid format required by frontend.
1144
+ encoded_redirect = f"{encoded_redirect}?state={state}"
1145
+ return (
1146
+ f"{config.SKILL_HUB_AUTH_URL}"
1147
+ f"?skill_code={urllib.parse.quote(skill_code, safe='')}"
1148
+ f"&redirect_uri={encoded_redirect}"
1149
+ )
1150
+
1151
+
1152
+ # ------------- commands -------------
1153
+
1154
+
1155
+ def _fast_decrypt(cipher: str) -> int:
1156
+ """Shortcut path: directly decrypt encryptedPayload ciphertext and persist.
1157
+
1158
+ For VM/sandbox environments where 127.0.0.1 is unreachable: Agent intercepts
1159
+ redirect URL via browser automation to get encryptedPayload, decrypts directly without local HTTP callback.
1160
+ """
1161
+ logger.info("_fast_decrypt: cipher.len=%d (--encrypted-payload mode)", len(cipher))
1162
+ print("[login] --encrypted-payload mode: skipping callback server, direct decrypt & persist")
1163
+
1164
+ # 1. Load keypair
1165
+ kp: Optional[Dict] = None
1166
+ try:
1167
+ kp = load_keypair()
1168
+ except SkillKeypairError as e:
1169
+ print(f"[login] {e}", file=sys.stderr)
1170
+ print("[login] --encrypted-payload mode requires .keypair.json for decryption.", file=sys.stderr)
1171
+ return 7
1172
+
1173
+ # 2. RSA segmented decryption
1174
+ logger.info(
1175
+ "fast_decrypt: cipher.len=%d sign_method=%s",
1176
+ len(cipher), kp.get("sign_method", ""),
1177
+ )
1178
+ decrypt_started = time.time()
1179
+ try:
1180
+ plaintext = decrypt_payload(cipher, kp["private_key_pem"], kp["sign_method"])
1181
+ except SkillAuthDecryptError as e:
1182
+ logger.error("fast_decrypt failed: %s", e)
1183
+ print(f"[login] DECRYPT_FAILED: {e}", file=sys.stderr)
1184
+ print(" Please verify the private key in .keypair.json matches the server public key.", file=sys.stderr)
1185
+ return 7
1186
+ logger.info(
1187
+ "fast_decrypt done in %.3fs: plaintext.keys=%s",
1188
+ time.time() - decrypt_started,
1189
+ sorted(plaintext.keys()) if isinstance(plaintext, dict) else type(plaintext).__name__,
1190
+ )
1191
+
1192
+ # 3. Persist
1193
+ creds = _payload_to_creds(plaintext)
1194
+ if not creds.get("MIRAVIA_ACCESS_TOKEN") or not creds.get("MIRAVIA_APP_KEY"):
1195
+ print(
1196
+ f"[login] Plaintext missing appKey / accessToken: {plaintext}",
1197
+ file=sys.stderr,
1198
+ )
1199
+ return 4
1200
+ if not creds.get("MIRAVIA_APP_SECRET"):
1201
+ print(
1202
+ "[login] WARNING: plaintext does not contain appSecret; business API signing will fail.\n"
1203
+ " Please contact Skill Hub to add appSecret field in authRule target interface.",
1204
+ file=sys.stderr,
1205
+ )
1206
+
1207
+ save_creds(creds)
1208
+ logger.info("_fast_decrypt done: creds written, returning 0")
1209
+ print(f"[login] OK, credentials written to: {config.CRED_FILE}")
1210
+ print(f"[login] app_key = {creds.get('MIRAVIA_APP_KEY', '')}")
1211
+ print(f"[login] app_secret = {config.mask_token(creds.get('MIRAVIA_APP_SECRET', ''))}")
1212
+ print(f"[login] access_token exp = {_fmt_expires(creds.get('MIRAVIA_TOKEN_EXPIRES_AT', ''))}")
1213
+ return 0
1214
+
1215
+
1216
+ def cmd_login(args: argparse.Namespace) -> int:
1217
+ """Skill Hub authorization flow (streamlined): browser redirects to seller center auth page ->
1218
+ frontend sends encryptedPayload back to local -> CLI decrypts with RSA private key -> credentials.json persisted.
1219
+
1220
+ If .keypair.json does not exist locally, enters debug mode: only opens browser,
1221
+ starts local callback, prints encryptedPayload after receiving it, no decryption.
1222
+
1223
+ --encrypted-payload shortcut (VM/sandbox environment):
1224
+ When this parameter is provided, skips local HTTP callback server and browser opening,
1225
+ directly decrypts the ciphertext with private key and persists. For scenarios where
1226
+ Agent intercepts redirect URL via browser automation to extract encryptedPayload.
1227
+ """
1228
+ # ── Shortcut path: directly pass encryptedPayload ciphertext, skip callback server ──
1229
+ if args.encrypted_payload:
1230
+ return _fast_decrypt(args.encrypted_payload)
1231
+
1232
+ host = "127.0.0.1"
1233
+ port = args.port
1234
+ redirect_uri = args.redirect_uri or f"http://{host}:{port}/callback"
1235
+ logger.info(
1236
+ "cmd_login begin: host=%s port=%d redirect_uri=%s no_open=%s timeout=%.1fs",
1237
+ host, port, redirect_uri, args.no_open, args.timeout,
1238
+ )
1239
+
1240
+ # 1. Attempt to load keypair (optional) and resolve skill_code
1241
+ kp: Optional[Dict] = None
1242
+ try:
1243
+ kp = load_keypair()
1244
+ except SkillKeypairError as e:
1245
+ print(f"[login] {e}", file=sys.stderr)
1246
+ print("[login] Entering debug mode: only verifying callback channel; will NOT decrypt/persist after receiving encryptedPayload.", file=sys.stderr)
1247
+
1248
+ skill_code = (
1249
+ args.skill_code
1250
+ or (kp["skill_code"] if kp else "")
1251
+ or config.DEFAULT_SKILL_CODE
1252
+ )
1253
+ if not skill_code:
1254
+ print("[login] Missing skill_code. Please use --skill-code or create .keypair.json", file=sys.stderr)
1255
+ return 1
1256
+
1257
+ # 2. Generate CSRF state (32-byte random token, single-use)
1258
+ csrf_state = _generate_csrf_state()
1259
+ logger.info("CSRF state generated: %s...%s", csrf_state[:4], csrf_state[-4:])
1260
+
1261
+ # 3. Check local port, then start callback server first (avoid browser callback before bind race condition)
1262
+ ok, err = _check_port_free(host, port)
1263
+ if not ok:
1264
+ print(f"[login] Port {host}:{port} unavailable: {err}", file=sys.stderr)
1265
+ print(" Try using --port to specify a different port.", file=sys.stderr)
1266
+ return 1
1267
+
1268
+ try:
1269
+ server = _start_callback_server(host, port, keypair=kp, csrf_state=csrf_state)
1270
+ except OSError as e:
1271
+ print(f"[login] Failed to start local callback server: {e}", file=sys.stderr)
1272
+ return 1
1273
+ print(f"[login] callback server ready at http://{host}:{port}/callback")
1274
+ print(f"[login] Detailed timing log : {LOG_FILE}")
1275
+
1276
+ # 4. Build authorization URL (with CSRF state)
1277
+ url = _build_hub_authorize_url(skill_code, redirect_uri, state=csrf_state)
1278
+
1279
+ print(f"[login] auth_entry = {config.SKILL_HUB_AUTH_URL}")
1280
+ print(f"[login] redirect_uri = {redirect_uri}")
1281
+ print(f"[login] skill_code = {skill_code}")
1282
+ print(f"[login] auth_url:\n {url}")
1283
+ # Verify server is listening (TCP connect probe) before opening browser
1284
+ _wait_until_listening(host, port)
1285
+ if not args.no_open:
1286
+ try:
1287
+ logger.info("webbrowser.open: %s", url)
1288
+ webbrowser.open(url, new=1)
1289
+ logger.info("webbrowser.open returned (handed off to OS)")
1290
+ except Exception as e: # noqa: BLE001
1291
+ logger.warning("webbrowser.open failed: %r", e)
1292
+ else:
1293
+ logger.info("--no-open specified, skipping browser launch")
1294
+
1295
+ # 5. Wait for browser GET 302 callback
1296
+ print(f"[login] Waiting for browser callback (up to {args.timeout:.0f}s)...")
1297
+ try:
1298
+ cb = _wait_callback(server, timeout=args.timeout)
1299
+ except TimeoutError as e:
1300
+ print(f"[login] {e}", file=sys.stderr)
1301
+ return 2
1302
+
1303
+ # 6. Server error passthrough (including CSRF rejection)
1304
+ if cb.get("error") or cb.get("error_description"):
1305
+ err_code = cb.get("error") or ""
1306
+ err_msg = cb.get("error_description") or ""
1307
+ print(f"[login] Server returned error: {err_code} {err_msg}", file=sys.stderr)
1308
+ return 4
1309
+
1310
+ cipher = cb.get("encryptedPayload")
1311
+ if not cipher:
1312
+ print(f"[login] No encryptedPayload received. Callback params: {cb}", file=sys.stderr)
1313
+ return 4
1314
+
1315
+ # 7. Debug mode: no private key, just print.
1316
+ if kp is None:
1317
+ tail = cipher[-16:] if len(cipher) > 16 else cipher
1318
+ print("[login] \u2705 Received encryptedPayload (debug mode, not decrypted)")
1319
+ print(f"[login] encryptedPayload length = {len(cipher)} chars, tail: ...{tail}")
1320
+ print("[login] Please provide .keypair.json (skill_code + private_key) to enable decryption and persistence.")
1321
+ return 0
1322
+
1323
+ # 8. Reuse callback thread's pre-decrypted result (decryption moved earlier; main thread no longer repeats RSA segmented decryption)
1324
+ creds = getattr(server, "preflight_creds", None)
1325
+ if creds is None:
1326
+ preflight_err = getattr(server, "preflight_decrypt_error", "")
1327
+ if preflight_err:
1328
+ print(f"[login] DECRYPT_FAILED: {preflight_err}", file=sys.stderr)
1329
+ print(" Please verify the private key in .keypair.json matches the server's public key.", file=sys.stderr)
1330
+ return 7
1331
+ # Theoretically do_GET always attempts decryption when kp exists;
1332
+ # reaching here means callback thread didn't reach decryption branch
1333
+ # (extreme race condition); fallback decrypt to ensure cmd_login behavior doesn't regress.
1334
+ logger.warning("preflight_creds missing, falling back to main-thread decrypt")
1335
+ decrypt_started = time.time()
1336
+ creds, err = _decrypt_cipher_to_creds(cipher, kp["private_key_pem"], kp["sign_method"])
1337
+ if err:
1338
+ logger.error("decrypt failed: %s", err)
1339
+ print(f"[login] DECRYPT_FAILED: {err}", file=sys.stderr)
1340
+ print(" Please verify the private key in .keypair.json matches the server's public key.", file=sys.stderr)
1341
+ return 7
1342
+ logger.info(
1343
+ "fallback decrypt done in %.3fs: creds.keys=%s",
1344
+ time.time() - decrypt_started, sorted(creds.keys()),
1345
+ )
1346
+ else:
1347
+ logger.info("reuse preflight_creds: skip main-thread decrypt")
1348
+
1349
+ # 9. Persist credentials
1350
+ if not creds.get("MIRAVIA_ACCESS_TOKEN") or not creds.get("MIRAVIA_APP_KEY"):
1351
+ print(
1352
+ f"[login] Decrypted payload missing appKey / accessToken: {creds}",
1353
+ file=sys.stderr,
1354
+ )
1355
+ return 4
1356
+ if not creds.get("MIRAVIA_APP_SECRET"):
1357
+ # appSecret is required for business API signing; server must provide it, otherwise Skill cannot call business APIs
1358
+ print(
1359
+ "[login] WARNING: decrypted payload does not contain appSecret; business API signing will fail.\n"
1360
+ " Please contact Skill Hub to add appSecret field in authRule target interface.",
1361
+ file=sys.stderr,
1362
+ )
1363
+
1364
+ save_creds(creds)
1365
+ # 10. Close the callback browser tab (best-effort, after 10s countdown)
1366
+ _close_browser_tab(
1367
+ url_fragment=f"{host}:{port}/callback",
1368
+ delay=10.0,
1369
+ )
1370
+ logger.info("cmd_login done: creds written, returning 0")
1371
+ print(f"[login] OK, credentials written to: {config.CRED_FILE}")
1372
+ print(f"[login] app_key = {creds.get('MIRAVIA_APP_KEY', '')}")
1373
+ print(f"[login] app_secret = {config.mask_token(creds.get('MIRAVIA_APP_SECRET', ''))}")
1374
+ print(f"[login] access_token exp = {_fmt_expires(creds.get('MIRAVIA_TOKEN_EXPIRES_AT', ''))}")
1375
+ print()
1376
+ print("To export as environment variables in shell, manually read credentials.json")
1377
+ return 0
1378
+
1379
+
1380
+ def cmd_status(_args: argparse.Namespace) -> int:
1381
+ creds = load_creds()
1382
+ if not creds:
1383
+ print(f"[status] Credentials file not found: {config.CRED_FILE}")
1384
+ print("[status] Please run first: python auth_cli.py login")
1385
+ return 1
1386
+ at = creds.get("MIRAVIA_ACCESS_TOKEN", "")
1387
+ sec = creds.get("MIRAVIA_APP_SECRET", "")
1388
+ print(f"[status] file = {config.CRED_FILE}")
1389
+ print(f"[status] app_key = {creds.get('MIRAVIA_APP_KEY') or '<empty>'}")
1390
+ print(f"[status] app_secret = {config.mask_token(sec)}")
1391
+ print(f"[status] last_login_at = {creds.get('MIRAVIA_LAST_LOGIN_AT', '<unknown>')}")
1392
+ print(f"[status] access_token = {config.mask_token(at)} exp = "
1393
+ f"{_fmt_expires(creds.get('MIRAVIA_TOKEN_EXPIRES_AT', ''))}")
1394
+ return 0
1395
+
1396
+
1397
+ def main(argv=None) -> int:
1398
+ log_path = _configure_logging()
1399
+ logger.info("=" * 60)
1400
+ logger.info("auth_cli main start: argv=%s log_file=%s", argv, log_path)
1401
+ p = argparse.ArgumentParser(description="Miravia Skill Hub Authorization CLI")
1402
+ sub = p.add_subparsers(dest="cmd", required=True)
1403
+
1404
+ p_login = sub.add_parser(
1405
+ "login",
1406
+ help="Run Skill Hub authorization flow (pre-release auth page -> local callback -> RSA decrypt & persist)",
1407
+ )
1408
+ p_login.add_argument("--port", type=int, default=8765, help="Local callback port (default 8765)")
1409
+ p_login.add_argument(
1410
+ "--redirect-uri",
1411
+ default=None,
1412
+ help="Custom redirect_uri; default http://127.0.0.1:<port>/callback",
1413
+ )
1414
+ p_login.add_argument(
1415
+ "--skill-code",
1416
+ default=None,
1417
+ help="Manually specify skill_code (overrides .keypair.json / environment variable default)",
1418
+ )
1419
+ p_login.add_argument("--timeout", type=float, default=180.0, help="Callback wait timeout in seconds (default 3 minutes)")
1420
+ p_login.add_argument("--no-open", action="store_true", help="Do not auto-open browser")
1421
+ p_login.add_argument(
1422
+ "--encrypted-payload",
1423
+ default=None,
1424
+ metavar="CIPHER",
1425
+ help=(
1426
+ "Pass encryptedPayload ciphertext (Base64URL) directly, skipping local HTTP callback server. "
1427
+ "For VM/sandbox environments where 127.0.0.1 is unreachable: Agent extracts "
1428
+ "encryptedPayload parameter from redirect URL via browser automation, then feeds it "
1429
+ "to this option for decryption and persistence."
1430
+ ),
1431
+ )
1432
+ p_login.set_defaults(func=cmd_login)
1433
+
1434
+ p_status = sub.add_parser("status", help="View local credential status and expiry")
1435
+ p_status.set_defaults(func=cmd_status)
1436
+
1437
+ args = p.parse_args(argv)
1438
+ return args.func(args)
1439
+
1440
+
1441
+ if __name__ == "__main__":
1442
+ sys.exit(main())