@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.
- package/bin/cli.js +61 -0
- package/dist/.keypair.json +5 -0
- package/dist/README.md +249 -0
- package/dist/SKILL.md +329 -0
- package/dist/VERSION +1 -0
- package/dist/executive_summary_prompt_v2.md +574 -0
- package/dist/reference.md +409 -0
- package/dist/requirements.txt +6 -0
- package/dist/scripts/auth_cli.py +1442 -0
- package/dist/scripts/config.py +216 -0
- package/dist/scripts/cred_crypto.py +235 -0
- package/dist/scripts/estimate_time.py +244 -0
- package/dist/scripts/export_orders.py +1549 -0
- package/dist/scripts/i18n.py +1162 -0
- package/dist/scripts/inject_insights.py +96 -0
- package/dist/scripts/login.bat +14 -0
- package/dist/scripts/miravia_client.py +468 -0
- package/dist/scripts/report_v4.py +1694 -0
- package/dist/scripts/run.bat +15 -0
- package/dist/scripts/skill_crypto.py +86 -0
- package/dist/scripts/skill_keypair.py +86 -0
- package/dist/scripts/skill_logger.py +262 -0
- package/dist/scripts/test_v4_render.py +322 -0
- package/dist/scripts/trace_logger.py +97 -0
- package/dist/scripts/validate_render.py +101 -0
- package/dist/static/chart.umd.min.js +20 -0
- package/dist/static/chartjs-plugin-datalabels.min.js +7 -0
- package/dist/static/miravia-logo.svg +61 -0
- package/dist/templates/report_v4.html +1005 -0
- package/lib/build-dist.js +86 -0
- package/lib/clean-dist.js +18 -0
- package/lib/installer.js +444 -0
- package/package.json +34 -0
|
@@ -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())
|