burnedsecret-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- burnedsecret_cli/__init__.py +3 -0
- burnedsecret_cli/auth_flow.py +369 -0
- burnedsecret_cli/client_factory.py +41 -0
- burnedsecret_cli/commands/__init__.py +1 -0
- burnedsecret_cli/commands/_ttl.py +77 -0
- burnedsecret_cli/commands/auth.py +216 -0
- burnedsecret_cli/commands/key.py +147 -0
- burnedsecret_cli/commands/request.py +271 -0
- burnedsecret_cli/commands/secret.py +302 -0
- burnedsecret_cli/config.py +251 -0
- burnedsecret_cli/errors.py +120 -0
- burnedsecret_cli/main.py +159 -0
- burnedsecret_cli/output.py +91 -0
- burnedsecret_cli-1.0.0.dist-info/METADATA +278 -0
- burnedsecret_cli-1.0.0.dist-info/RECORD +17 -0
- burnedsecret_cli-1.0.0.dist-info/WHEEL +4 -0
- burnedsecret_cli-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""The reusable D-10 PKCE + loopback browser-login recipe (client half).
|
|
2
|
+
|
|
3
|
+
This module owns the terminal side of ``bs auth login``: it performs an
|
|
4
|
+
RFC 7636 PKCE S256 + RFC 8252 loopback-redirect flow against the web
|
|
5
|
+
``/cli-auth`` consent page (plan 11-02) and the ``POST /v1/cli-auth/exchange``
|
|
6
|
+
endpoint (plan 11-01), and returns a freshly minted long-lived API key.
|
|
7
|
+
|
|
8
|
+
Design constraints (security-critical):
|
|
9
|
+
* stdlib ONLY — no HTTP client dependency. The two ``/v1/cli-auth/*`` calls
|
|
10
|
+
this module makes use ``urllib.request``; the SDK owns its own transport
|
|
11
|
+
for the business endpoints and is intentionally not reused here.
|
|
12
|
+
* The API key is returned ONLY in the exchange RESPONSE BODY. It is never
|
|
13
|
+
printed and never placed in any URL. The browser callback carries only
|
|
14
|
+
``code`` + ``state`` (and, on failure, ``error``).
|
|
15
|
+
* The single-use ``code`` (60s, PKCE-bound) and the long-lived ``api_key``
|
|
16
|
+
it is exchanged for are kept terminologically and lifecycle-wise distinct.
|
|
17
|
+
* The returned ``state`` is compared against the sent CSRF ``state``; a
|
|
18
|
+
mismatch aborts login and stores nothing (the caller never sees a key).
|
|
19
|
+
|
|
20
|
+
Flow (``run_login``):
|
|
21
|
+
1. Generate a CSRF ``state``, a PKCE ``(verifier, challenge)`` pair, and pick
|
|
22
|
+
a free loopback port.
|
|
23
|
+
2. Start a one-shot ``127.0.0.1:<port>`` HTTP server (in a thread, with a
|
|
24
|
+
timeout) that captures ``?code=&state=`` / ``?error=``, validates the
|
|
25
|
+
returned ``state``, serves a terminal-themed "you can close this tab"
|
|
26
|
+
page, and shuts down after one request.
|
|
27
|
+
3. Open the browser to the web ``/cli-auth`` authorize URL (carrying the
|
|
28
|
+
loopback callback, the CSRF state, and the S256 challenge). The URL is
|
|
29
|
+
also echoed to STDERR for the headless / can't-open-browser fallback.
|
|
30
|
+
4. Exchange the captured ``code`` + the PKCE ``verifier`` at
|
|
31
|
+
``/v1/cli-auth/exchange`` and return ``{api_key, key_prefix, name,
|
|
32
|
+
hostname}``.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import hashlib
|
|
38
|
+
import http.server
|
|
39
|
+
import json
|
|
40
|
+
import secrets
|
|
41
|
+
import socket
|
|
42
|
+
import sys
|
|
43
|
+
import threading
|
|
44
|
+
import urllib.error
|
|
45
|
+
import urllib.parse
|
|
46
|
+
import urllib.request
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from typing import Optional
|
|
49
|
+
|
|
50
|
+
# How long we wait for the browser round-trip before giving up. The server's
|
|
51
|
+
# single-use code TTL is 60s; we allow a little longer for the human to land on
|
|
52
|
+
# the consent page and click Authorize.
|
|
53
|
+
_LOGIN_TIMEOUT_SECONDS = 120
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthFlowError(Exception):
|
|
57
|
+
"""A recoverable login-flow failure (CSRF mismatch, timeout, exchange error).
|
|
58
|
+
|
|
59
|
+
Carries a human-facing message only; never embeds the verifier, the code,
|
|
60
|
+
or the minted key.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pkce_pair() -> tuple[str, str]:
|
|
65
|
+
"""Return an RFC 7636 ``(code_verifier, code_challenge)`` S256 pair.
|
|
66
|
+
|
|
67
|
+
``verifier`` = base64url(32 random bytes) with padding stripped.
|
|
68
|
+
``challenge`` = base64url(sha256(verifier)) with padding stripped — exactly
|
|
69
|
+
the transform the server's ``_compute_pkce_challenge`` recomputes and
|
|
70
|
+
constant-time compares against the stored challenge.
|
|
71
|
+
"""
|
|
72
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
|
|
73
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
74
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
75
|
+
return verifier, challenge
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def free_port() -> int:
|
|
79
|
+
"""Bind ``127.0.0.1:0`` to grab an OS-assigned free port, then release it.
|
|
80
|
+
|
|
81
|
+
There is an inherent TOCTOU window between releasing the port here and the
|
|
82
|
+
one-shot server re-binding it; in practice this is fine for a same-machine,
|
|
83
|
+
same-process loopback login and matches the RFC 8252 native-app pattern.
|
|
84
|
+
"""
|
|
85
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
86
|
+
sock.bind(("127.0.0.1", 0))
|
|
87
|
+
return sock.getsockname()[1]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def web_origin_from_base_url(base_url: Optional[str]) -> str:
|
|
91
|
+
"""Derive the web origin (scheme://host[:port]) hosting ``/cli-auth``.
|
|
92
|
+
|
|
93
|
+
``base_url`` is the configured API base (e.g.
|
|
94
|
+
``https://burnedsecret.com/api/v1``). The web consent page lives at the same
|
|
95
|
+
origin's ``/cli-auth`` path, so we strip the API path entirely and keep only
|
|
96
|
+
the origin. ``None`` (no override) maps to the production origin.
|
|
97
|
+
"""
|
|
98
|
+
if not base_url:
|
|
99
|
+
return "https://burnedsecret.com"
|
|
100
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
101
|
+
if not parsed.scheme or not parsed.netloc:
|
|
102
|
+
# Not a parseable URL — fall back to production rather than build a
|
|
103
|
+
# malformed authorize URL.
|
|
104
|
+
return "https://burnedsecret.com"
|
|
105
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def api_base_from_base_url(base_url: Optional[str]) -> str:
|
|
109
|
+
"""Derive the ``/api/v1`` base used for the exchange POST.
|
|
110
|
+
|
|
111
|
+
Honors an explicit configured override; otherwise returns the production
|
|
112
|
+
``https://burnedsecret.com/api/v1`` (matching the SDK's own default). The
|
|
113
|
+
returned value has no trailing slash.
|
|
114
|
+
"""
|
|
115
|
+
if base_url:
|
|
116
|
+
return base_url.rstrip("/")
|
|
117
|
+
return "https://burnedsecret.com/api/v1"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Terminal-themed confirmation page served to the browser after a successful
|
|
121
|
+
# (or failed) callback. Self-contained, no external assets.
|
|
122
|
+
_SUCCESS_HTML = """<!doctype html>
|
|
123
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
124
|
+
<title>BurnedSecret CLI</title>
|
|
125
|
+
<style>
|
|
126
|
+
html,body{height:100%;margin:0}
|
|
127
|
+
body{display:flex;align-items:center;justify-content:center;
|
|
128
|
+
background:#0b0f10;color:#d6f5d6;
|
|
129
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
130
|
+
.card{text-align:center;padding:2rem 2.5rem;border:1px solid #1f2d1f;
|
|
131
|
+
border-radius:10px;background:#0e1412}
|
|
132
|
+
.ok{font-size:1.4rem;color:#7CFC7C}
|
|
133
|
+
.sub{margin-top:.5rem;color:#8aa88a;font-size:.95rem}
|
|
134
|
+
</style></head>
|
|
135
|
+
<body><div class="card">
|
|
136
|
+
<div class="ok">✓ Authorized</div>
|
|
137
|
+
<div class="sub">Return to your terminal — you can close this tab.</div>
|
|
138
|
+
</div></body></html>"""
|
|
139
|
+
|
|
140
|
+
_ERROR_HTML = """<!doctype html>
|
|
141
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
142
|
+
<title>BurnedSecret CLI</title>
|
|
143
|
+
<style>
|
|
144
|
+
html,body{height:100%;margin:0}
|
|
145
|
+
body{display:flex;align-items:center;justify-content:center;
|
|
146
|
+
background:#0b0f10;color:#f5d6d6;
|
|
147
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
148
|
+
.card{text-align:center;padding:2rem 2.5rem;border:1px solid #2d1f1f;
|
|
149
|
+
border-radius:10px;background:#140e0e}
|
|
150
|
+
.err{font-size:1.4rem;color:#FC7C7C}
|
|
151
|
+
.sub{margin-top:.5rem;color:#a88a8a;font-size:.95rem}
|
|
152
|
+
</style></head>
|
|
153
|
+
<body><div class="card">
|
|
154
|
+
<div class="err">✗ Login failed</div>
|
|
155
|
+
<div class="sub">Return to your terminal for details — you can close this tab.</div>
|
|
156
|
+
</div></body></html>"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class _CallbackResult:
|
|
161
|
+
"""Mutable holder the one-shot handler writes the captured params into."""
|
|
162
|
+
|
|
163
|
+
code: Optional[str] = None
|
|
164
|
+
error: Optional[str] = None
|
|
165
|
+
state_ok: bool = False
|
|
166
|
+
received: threading.Event = field(default_factory=threading.Event)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _make_handler(expected_state: str, result: _CallbackResult):
|
|
170
|
+
"""Build a one-shot ``BaseHTTPRequestHandler`` bound to this login attempt.
|
|
171
|
+
|
|
172
|
+
Captures ``code``/``state``/``error`` from the first GET, validates the CSRF
|
|
173
|
+
``state`` against ``expected_state``, serves the confirmation page, and signals
|
|
174
|
+
``result.received``. Subsequent requests (favicon, etc.) get a bare 204.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
178
|
+
# Silence the default stderr request logging (it would pollute the
|
|
179
|
+
# CLI's stderr discipline with raw HTTP log lines).
|
|
180
|
+
def log_message(self, *args, **kwargs): # noqa: D401, ANN001
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
def _respond(self, status: int, html: str) -> None:
|
|
184
|
+
body = html.encode("utf-8")
|
|
185
|
+
self.send_response(status)
|
|
186
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
187
|
+
self.send_header("Content-Length", str(len(body)))
|
|
188
|
+
self.end_headers()
|
|
189
|
+
self.wfile.write(body)
|
|
190
|
+
|
|
191
|
+
def do_GET(self) -> None: # noqa: N802 (http.server API)
|
|
192
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
193
|
+
# Ignore everything but the callback root (e.g. /favicon.ico).
|
|
194
|
+
if result.received.is_set():
|
|
195
|
+
self._respond(204, "")
|
|
196
|
+
return
|
|
197
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
198
|
+
error = params.get("error", [None])[0]
|
|
199
|
+
code = params.get("code", [None])[0]
|
|
200
|
+
returned_state = params.get("state", [None])[0]
|
|
201
|
+
|
|
202
|
+
if error:
|
|
203
|
+
result.error = error
|
|
204
|
+
self._respond(400, _ERROR_HTML)
|
|
205
|
+
result.received.set()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# CSRF: the returned state MUST match the one we sent. A mismatch is
|
|
209
|
+
# a hard abort — we capture NO code, so no exchange happens.
|
|
210
|
+
if returned_state != expected_state:
|
|
211
|
+
result.state_ok = False
|
|
212
|
+
result.error = "state_mismatch"
|
|
213
|
+
self._respond(400, _ERROR_HTML)
|
|
214
|
+
result.received.set()
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if not code:
|
|
218
|
+
result.error = "missing_code"
|
|
219
|
+
self._respond(400, _ERROR_HTML)
|
|
220
|
+
result.received.set()
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
result.state_ok = True
|
|
224
|
+
result.code = code
|
|
225
|
+
self._respond(200, _SUCCESS_HTML)
|
|
226
|
+
result.received.set()
|
|
227
|
+
|
|
228
|
+
return _Handler
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _exchange_code(api_base: str, code: str, code_verifier: str) -> dict:
|
|
232
|
+
"""POST ``{code, code_verifier}`` to ``/cli-auth/exchange``; return its body.
|
|
233
|
+
|
|
234
|
+
Returns the parsed JSON ``{api_key, key_prefix, name}``. Raises
|
|
235
|
+
:class:`AuthFlowError` on any HTTP/transport/shape failure — never leaking
|
|
236
|
+
the verifier or code into the message.
|
|
237
|
+
"""
|
|
238
|
+
url = f"{api_base}/cli-auth/exchange"
|
|
239
|
+
payload = json.dumps({"code": code, "code_verifier": code_verifier}).encode("utf-8")
|
|
240
|
+
req = urllib.request.Request(
|
|
241
|
+
url,
|
|
242
|
+
data=payload,
|
|
243
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
244
|
+
method="POST",
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (https URL)
|
|
248
|
+
raw = resp.read()
|
|
249
|
+
except urllib.error.HTTPError as exc: # server said no (400 INVALID_CODE, etc.)
|
|
250
|
+
raise AuthFlowError(
|
|
251
|
+
"Code exchange was rejected by the server "
|
|
252
|
+
f"(HTTP {exc.code}). The login code may have expired — retry `bs auth login`."
|
|
253
|
+
) from None
|
|
254
|
+
except urllib.error.URLError as exc:
|
|
255
|
+
raise AuthFlowError(f"Could not reach the exchange endpoint: {exc.reason}") from None
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
body = json.loads(raw.decode("utf-8"))
|
|
259
|
+
except (ValueError, UnicodeDecodeError):
|
|
260
|
+
raise AuthFlowError("Exchange response was not valid JSON.") from None
|
|
261
|
+
|
|
262
|
+
api_key = body.get("api_key")
|
|
263
|
+
if not isinstance(api_key, str) or not api_key:
|
|
264
|
+
raise AuthFlowError("Exchange response did not contain an api_key.")
|
|
265
|
+
return body
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_authorize_url(
|
|
269
|
+
web_origin: str,
|
|
270
|
+
callback: str,
|
|
271
|
+
state: str,
|
|
272
|
+
challenge: str,
|
|
273
|
+
hostname: str,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Construct the web ``/cli-auth`` authorize URL the browser is sent to.
|
|
276
|
+
|
|
277
|
+
Carries the loopback ``callback``, the CSRF ``state``, the PKCE S256
|
|
278
|
+
``code_challenge`` (+ method), and the ``hostname`` to label the key. It
|
|
279
|
+
deliberately carries NO secret — only the public challenge and state.
|
|
280
|
+
"""
|
|
281
|
+
query = urllib.parse.urlencode(
|
|
282
|
+
{
|
|
283
|
+
"callback": callback,
|
|
284
|
+
"state": state,
|
|
285
|
+
"code_challenge": challenge,
|
|
286
|
+
"code_challenge_method": "S256",
|
|
287
|
+
"hostname": hostname,
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
return f"{web_origin}/cli-auth?{query}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_login(
|
|
294
|
+
base_url: Optional[str],
|
|
295
|
+
hostname: str,
|
|
296
|
+
*,
|
|
297
|
+
open_browser: bool = True,
|
|
298
|
+
timeout: int = _LOGIN_TIMEOUT_SECONDS,
|
|
299
|
+
) -> dict:
|
|
300
|
+
"""Run the full PKCE + loopback + exchange login and return the minted key.
|
|
301
|
+
|
|
302
|
+
``base_url`` is the configured API base (``None`` → production). ``hostname``
|
|
303
|
+
labels the minted key ("CLI - <hostname>"). Returns
|
|
304
|
+
``{api_key, key_prefix, name, hostname}`` on success.
|
|
305
|
+
|
|
306
|
+
Raises :class:`AuthFlowError` on CSRF/state mismatch, browser ``error=``
|
|
307
|
+
callback, timeout, or an exchange failure — in every failure path NO key is
|
|
308
|
+
returned, so the caller stores nothing.
|
|
309
|
+
"""
|
|
310
|
+
verifier, challenge = pkce_pair()
|
|
311
|
+
state = secrets.token_urlsafe(24)
|
|
312
|
+
port = free_port()
|
|
313
|
+
callback = f"http://127.0.0.1:{port}"
|
|
314
|
+
|
|
315
|
+
web_origin = web_origin_from_base_url(base_url)
|
|
316
|
+
api_base = api_base_from_base_url(base_url)
|
|
317
|
+
authorize_url = build_authorize_url(web_origin, callback, state, challenge, hostname)
|
|
318
|
+
|
|
319
|
+
result = _CallbackResult()
|
|
320
|
+
handler = _make_handler(state, result)
|
|
321
|
+
httpd = http.server.HTTPServer(("127.0.0.1", port), handler)
|
|
322
|
+
httpd.timeout = 1 # poll granularity for the serve loop
|
|
323
|
+
|
|
324
|
+
def _serve() -> None:
|
|
325
|
+
# Serve requests until the callback is received (or the thread is asked
|
|
326
|
+
# to stop via the daemon flag at interpreter exit).
|
|
327
|
+
while not result.received.is_set():
|
|
328
|
+
httpd.handle_request()
|
|
329
|
+
|
|
330
|
+
server_thread = threading.Thread(target=_serve, daemon=True)
|
|
331
|
+
server_thread.start()
|
|
332
|
+
|
|
333
|
+
# Always surface the URL on STDERR — both as the headless fallback and so a
|
|
334
|
+
# user whose browser didn't open can paste it manually.
|
|
335
|
+
print(f"Opening your browser to authorize this CLI:\n {authorize_url}", file=sys.stderr)
|
|
336
|
+
if open_browser:
|
|
337
|
+
import webbrowser
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
webbrowser.open(authorize_url)
|
|
341
|
+
except Exception: # noqa: BLE001 — headless: the URL is already on stderr
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
got_it = result.received.wait(timeout=timeout)
|
|
345
|
+
# Stop the server loop regardless of outcome.
|
|
346
|
+
httpd.server_close()
|
|
347
|
+
|
|
348
|
+
if not got_it:
|
|
349
|
+
raise AuthFlowError(
|
|
350
|
+
f"Timed out after {timeout}s waiting for browser authorization. "
|
|
351
|
+
"Retry `bs auth login`."
|
|
352
|
+
)
|
|
353
|
+
if result.error == "state_mismatch":
|
|
354
|
+
raise AuthFlowError(
|
|
355
|
+
"CSRF state mismatch — the browser callback did not match this login "
|
|
356
|
+
"request. Aborted; nothing was stored."
|
|
357
|
+
)
|
|
358
|
+
if result.error:
|
|
359
|
+
raise AuthFlowError(f"Authorization failed: {result.error}.")
|
|
360
|
+
if not result.code:
|
|
361
|
+
raise AuthFlowError("No authorization code was returned.")
|
|
362
|
+
|
|
363
|
+
body = _exchange_code(api_base, result.code, verifier)
|
|
364
|
+
return {
|
|
365
|
+
"api_key": body["api_key"],
|
|
366
|
+
"key_prefix": body.get("key_prefix"),
|
|
367
|
+
"name": body.get("name"),
|
|
368
|
+
"hostname": hostname,
|
|
369
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Build a shipped-SDK ``BurnedSecret`` client from resolved config.
|
|
2
|
+
|
|
3
|
+
The CRITICAL invariant: when the user has NOT configured a ``base_url``,
|
|
4
|
+
construct the client WITHOUT a ``base_url`` kwarg so the SDK applies its own
|
|
5
|
+
``_DEFAULT_BASE`` (``https://burnedsecret.com/api/v1`` — with ``/v1``). We never
|
|
6
|
+
pass a hardcoded ``.../api`` (no ``/v1``) string; that would 404 against the
|
|
7
|
+
live API. When the user DID configure an explicit ``base_url``, we honor it.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from burnedsecret import BurnedSecret
|
|
14
|
+
|
|
15
|
+
from .config import resolve_api_key, resolve_base_url
|
|
16
|
+
from .output import ExitCode, exit_with
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_client(cfg: dict, profile: str) -> BurnedSecret:
|
|
20
|
+
"""Construct a :class:`BurnedSecret` for ``profile`` using ``cfg``.
|
|
21
|
+
|
|
22
|
+
Resolves the API key (env > config) and base URL (profile > global > None).
|
|
23
|
+
Passes ``base_url`` to the constructor ONLY when an explicit override is
|
|
24
|
+
configured, so the SDK's correct ``/v1`` default applies otherwise.
|
|
25
|
+
|
|
26
|
+
Exits with :attr:`ExitCode.AUTH` and a friendly message if no API key is
|
|
27
|
+
resolved.
|
|
28
|
+
"""
|
|
29
|
+
api_key: Optional[str] = resolve_api_key(cfg, profile)
|
|
30
|
+
if not api_key:
|
|
31
|
+
exit_with(
|
|
32
|
+
"Not authenticated — run `bs auth login` or set "
|
|
33
|
+
"BURNEDSECRET_API_KEY.",
|
|
34
|
+
ExitCode.AUTH,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
base_url = resolve_base_url(cfg, profile)
|
|
38
|
+
if base_url:
|
|
39
|
+
return BurnedSecret(api_key=api_key, base_url=base_url)
|
|
40
|
+
# No override → let the SDK apply its own _DEFAULT_BASE (.../api/v1).
|
|
41
|
+
return BurnedSecret(api_key=api_key)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concrete CLI command groups (secret/request in plan 04; key/auth in plan 05)."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared TTL handling for ``bs secret`` and ``bs request`` (Conflict 3 / Pitfall 5).
|
|
2
|
+
|
|
3
|
+
The SDK's ``ttl=`` argument accepts ONLY the exact seconds in
|
|
4
|
+
``burnedsecret.client._VALID_TTLS == {300, 3600, 86400, 604800}``. Anything else
|
|
5
|
+
raises ``ValueError`` inside the SDK. The CLI exposes those four values as a
|
|
6
|
+
friendly 4-member Typer ``Enum`` (``5m``/``1h``/``1d``/``7d``), and translates
|
|
7
|
+
the human-facing config knob ``[global].default_ttl_days`` (in DAYS) into one of
|
|
8
|
+
those exact seconds — refusing day values that have no member rather than
|
|
9
|
+
silently computing ``days * 86400`` (which would hand the SDK an invalid TTL).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
|
|
15
|
+
from ..output import ExitCode, exit_with
|
|
16
|
+
|
|
17
|
+
# Default when neither --ttl nor config default_ttl_days is set: 1 day.
|
|
18
|
+
DEFAULT_TTL_SECONDS = 86400
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Ttl(str, enum.Enum):
|
|
22
|
+
"""The four TTL choices the SDK accepts, as human-friendly labels.
|
|
23
|
+
|
|
24
|
+
``.value`` is the label (what Typer parses / shows); ``.seconds`` is the
|
|
25
|
+
exact SDK seconds. A bad CLI value fails at parse time (exit 2) because
|
|
26
|
+
Typer validates against the enum members.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
FIVE_MIN = "5m"
|
|
30
|
+
ONE_HOUR = "1h"
|
|
31
|
+
ONE_DAY = "1d"
|
|
32
|
+
SEVEN_DAYS = "7d"
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def seconds(self) -> int:
|
|
36
|
+
return _TTL_TO_SECONDS[self]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_TTL_TO_SECONDS: dict[Ttl, int] = {
|
|
40
|
+
Ttl.FIVE_MIN: 300,
|
|
41
|
+
Ttl.ONE_HOUR: 3600,
|
|
42
|
+
Ttl.ONE_DAY: 86400,
|
|
43
|
+
Ttl.SEVEN_DAYS: 604800,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# The ONLY whole-day-expressible TTL members. 5m/1h are sub-day, so a
|
|
47
|
+
# ``default_ttl_days`` of anything other than 1 or 7 has no valid mapping.
|
|
48
|
+
_DAY_DEFAULT_MAP: dict[int, int] = {1: 86400, 7: 604800}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_ttl_seconds(ttl: Ttl | None, cfg: dict) -> int:
|
|
52
|
+
"""Resolve the effective TTL in SDK seconds.
|
|
53
|
+
|
|
54
|
+
Priority: an explicit ``--ttl`` enum (``ttl``) wins; otherwise the config's
|
|
55
|
+
``[global].default_ttl_days`` is mapped through :data:`_DAY_DEFAULT_MAP`;
|
|
56
|
+
otherwise :data:`DEFAULT_TTL_SECONDS` (1 day).
|
|
57
|
+
|
|
58
|
+
A ``default_ttl_days`` present but NOT in ``{1, 7}`` is a configuration
|
|
59
|
+
error: exit ``INVALID_ARGS`` WITHOUT contacting the SDK (never silently
|
|
60
|
+
forward ``days * 86400``).
|
|
61
|
+
"""
|
|
62
|
+
if ttl is not None:
|
|
63
|
+
return ttl.seconds
|
|
64
|
+
|
|
65
|
+
global_table = cfg.get("global")
|
|
66
|
+
if isinstance(global_table, dict) and "default_ttl_days" in global_table:
|
|
67
|
+
days = global_table.get("default_ttl_days")
|
|
68
|
+
if days in _DAY_DEFAULT_MAP:
|
|
69
|
+
return _DAY_DEFAULT_MAP[days]
|
|
70
|
+
exit_with(
|
|
71
|
+
f"Invalid [global].default_ttl_days = {days!r}: only "
|
|
72
|
+
f"{sorted(_DAY_DEFAULT_MAP)} (whole days) are supported. "
|
|
73
|
+
"Use --ttl for 5m/1h.",
|
|
74
|
+
ExitCode.INVALID_ARGS,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return DEFAULT_TTL_SECONDS
|