mkctl 0.4.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.
- mkctl/__init__.py +8 -0
- mkctl/cli.py +478 -0
- mkctl/client.py +131 -0
- mkctl/commands.py +527 -0
- mkctl/config.py +59 -0
- mkctl/oauth.py +398 -0
- mkctl/py.typed +0 -0
- mkctl/session.py +104 -0
- mkctl/store.py +90 -0
- mkctl-0.4.0.dist-info/METADATA +10 -0
- mkctl-0.4.0.dist-info/RECORD +14 -0
- mkctl-0.4.0.dist-info/WHEEL +4 -0
- mkctl-0.4.0.dist-info/entry_points.txt +2 -0
- mkctl-0.4.0.dist-info/licenses/LICENSE +201 -0
mkctl/oauth.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""The two flows a native application is allowed to use, and nothing else.
|
|
2
|
+
|
|
3
|
+
**Authorization code with PKCE over a loopback redirect** (RFC 8252 §7.3, RFC 7636)
|
|
4
|
+
is the default. It is what the OAuth native-app BCP asks for when the machine has a
|
|
5
|
+
browser: the client holds no secret, and the `code_verifier` binds the code to the
|
|
6
|
+
process that asked for it, so a code intercepted on its way back is useless.
|
|
7
|
+
|
|
8
|
+
**The device authorization grant** (RFC 8628) is the fallback, for the case the
|
|
9
|
+
loopback flow cannot serve: a terminal over SSH, a container, a machine with no
|
|
10
|
+
browser. The human authorizes on a device that does have one.
|
|
11
|
+
|
|
12
|
+
PKCE applies to the first and not to the second, and that is not an omission. PKCE
|
|
13
|
+
protects a redirect from interception; the device grant has no redirect and no code
|
|
14
|
+
travelling back through the user agent — the `device_code` goes straight to the
|
|
15
|
+
polling client over TLS. There is no `code_challenge` parameter in a device
|
|
16
|
+
authorization request. See ADR 0004 Amendment 2.
|
|
17
|
+
|
|
18
|
+
Neither flow uses a client secret. A secret shipped inside a CLI is a public value
|
|
19
|
+
with a misleading name (RFC 8252 §8.5).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import http.server
|
|
27
|
+
import secrets
|
|
28
|
+
import socketserver
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.parse
|
|
32
|
+
import webbrowser
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
|
|
39
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
40
|
+
|
|
41
|
+
# RFC 8628 §3.5. `slow_down` means add at least five seconds to the interval.
|
|
42
|
+
SLOW_DOWN_INCREMENT = 5
|
|
43
|
+
DEFAULT_INTERVAL = 5
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AuthorizationError(Exception):
|
|
47
|
+
"""The human did not authorize, or the provider refused."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Tokens:
|
|
52
|
+
access_token: str
|
|
53
|
+
refresh_token: str | None
|
|
54
|
+
expires_at: float
|
|
55
|
+
scope: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def expired(self) -> bool:
|
|
59
|
+
# A minute of slack: a token that expires mid-request is a failure the user
|
|
60
|
+
# sees, and refreshing slightly early costs nothing.
|
|
61
|
+
return time.time() >= self.expires_at - 60
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Endpoints:
|
|
66
|
+
"""Read from the provider's discovery document rather than configured.
|
|
67
|
+
|
|
68
|
+
Hard-coding paths means an IdP change is a code change, which is the coupling
|
|
69
|
+
ADR 0004 spent an adapter to avoid.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
issuer: str
|
|
73
|
+
authorization: str
|
|
74
|
+
token: str
|
|
75
|
+
device_authorization: str | None
|
|
76
|
+
revocation: str | None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def discover(cls, issuer: str, *, client: httpx.Client | None = None) -> Endpoints:
|
|
80
|
+
http_client = client or httpx.Client(timeout=10)
|
|
81
|
+
document = (
|
|
82
|
+
http_client.get(f"{issuer.rstrip('/')}/.well-known/openid-configuration")
|
|
83
|
+
.raise_for_status()
|
|
84
|
+
.json()
|
|
85
|
+
)
|
|
86
|
+
return cls(
|
|
87
|
+
issuer=document["issuer"],
|
|
88
|
+
authorization=document["authorization_endpoint"],
|
|
89
|
+
token=document["token_endpoint"],
|
|
90
|
+
device_authorization=document.get("device_authorization_endpoint"),
|
|
91
|
+
revocation=document.get("revocation_endpoint"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _pkce() -> tuple[str, str]:
|
|
96
|
+
"""A verifier and its S256 challenge (RFC 7636 sections 4.1 and 4.2)."""
|
|
97
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
98
|
+
challenge = (
|
|
99
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
100
|
+
)
|
|
101
|
+
return verifier, challenge
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _tokens_from(payload: dict[str, Any]) -> Tokens:
|
|
105
|
+
return Tokens(
|
|
106
|
+
access_token=payload["access_token"],
|
|
107
|
+
refresh_token=payload.get("refresh_token"),
|
|
108
|
+
expires_at=time.time() + int(payload.get("expires_in", 300)),
|
|
109
|
+
scope=payload.get("scope"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _AuthorizationCallbackServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
114
|
+
"""Single-use loopback endpoint for the OAuth authorization response (RFC 8252 §7.3).
|
|
115
|
+
|
|
116
|
+
Exactly one path is registered as `redirect_uri` (`/callback`). The authorization
|
|
117
|
+
response is the query on that path (`code` + `state`, or `error`). The server records
|
|
118
|
+
the first valid response and signals completion; the listen socket is closed by the
|
|
119
|
+
waiting client after that. Non-callback requests are not an authorization response
|
|
120
|
+
and must not be treated as one (or as a reason to discard one already received).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
daemon_threads = True
|
|
124
|
+
allow_reuse_address = True
|
|
125
|
+
|
|
126
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
127
|
+
super().__init__(*args, **kwargs)
|
|
128
|
+
self._lock = threading.Lock()
|
|
129
|
+
self.authorization_response: dict[str, str] | None = None
|
|
130
|
+
self.completed = threading.Event()
|
|
131
|
+
|
|
132
|
+
def accept_authorization_response(self, params: dict[str, str]) -> bool:
|
|
133
|
+
"""Store the first authorization response. Returns whether this call stored it."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
if self.authorization_response is not None:
|
|
136
|
+
return False
|
|
137
|
+
self.authorization_response = params
|
|
138
|
+
self.completed.set()
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _AuthorizationCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
143
|
+
"""HTTP handler for `redirect_uri` only."""
|
|
144
|
+
|
|
145
|
+
server: _AuthorizationCallbackServer
|
|
146
|
+
|
|
147
|
+
def do_GET(self) -> None: # http.server's interface, not our naming
|
|
148
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
149
|
+
if parsed.path != "/callback":
|
|
150
|
+
self.send_error(404, "Not Found")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
|
|
154
|
+
if "code" not in params and "error" not in params:
|
|
155
|
+
self.send_error(400, "Expected an OAuth authorization response")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self.server.accept_authorization_response(params)
|
|
159
|
+
body = b"<html><body><p>Signed in. You can close this tab.</p></body></html>"
|
|
160
|
+
self.send_response(200)
|
|
161
|
+
self.send_header("content-type", "text/html; charset=utf-8")
|
|
162
|
+
self.send_header("content-length", str(len(body)))
|
|
163
|
+
self.end_headers()
|
|
164
|
+
self.wfile.write(body)
|
|
165
|
+
|
|
166
|
+
def log_message(self, *args: object) -> None:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def login_loopback(
|
|
171
|
+
endpoints: Endpoints,
|
|
172
|
+
client_id: str,
|
|
173
|
+
*,
|
|
174
|
+
scope: str = "openid profile email offline_access",
|
|
175
|
+
open_browser: bool = True,
|
|
176
|
+
timeout: float = 300,
|
|
177
|
+
client: httpx.Client | None = None,
|
|
178
|
+
) -> Tokens:
|
|
179
|
+
"""Authorization code with PKCE, redirected to a loopback address.
|
|
180
|
+
|
|
181
|
+
The listener binds to 127.0.0.1 on a port the OS picks (RFC 8252 §7.3 requires
|
|
182
|
+
the loopback interface and requires the provider to accept any port). `state` is
|
|
183
|
+
checked because a redirect is a request anything on this machine could make.
|
|
184
|
+
"""
|
|
185
|
+
verifier, challenge = _pkce()
|
|
186
|
+
state = secrets.token_urlsafe(16)
|
|
187
|
+
|
|
188
|
+
server = _AuthorizationCallbackServer(("127.0.0.1", 0), _AuthorizationCallbackHandler)
|
|
189
|
+
redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
|
|
190
|
+
|
|
191
|
+
query = urllib.parse.urlencode(
|
|
192
|
+
{
|
|
193
|
+
"client_id": client_id,
|
|
194
|
+
"response_type": "code",
|
|
195
|
+
"redirect_uri": redirect_uri,
|
|
196
|
+
"scope": scope,
|
|
197
|
+
"state": state,
|
|
198
|
+
"code_challenge": challenge,
|
|
199
|
+
"code_challenge_method": "S256",
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
url = f"{endpoints.authorization}?{query}"
|
|
203
|
+
|
|
204
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
205
|
+
thread.start()
|
|
206
|
+
try:
|
|
207
|
+
if open_browser:
|
|
208
|
+
webbrowser.open(url)
|
|
209
|
+
print(f"Opening {url}")
|
|
210
|
+
print(f"Waiting for authorization redirect to {redirect_uri}")
|
|
211
|
+
if not server.completed.wait(timeout=timeout):
|
|
212
|
+
raise AuthorizationError("timed out waiting for the browser to come back")
|
|
213
|
+
finally:
|
|
214
|
+
# Always free the port — including when the human hits Ctrl+C mid-wait.
|
|
215
|
+
server.shutdown()
|
|
216
|
+
server.server_close()
|
|
217
|
+
|
|
218
|
+
result = server.authorization_response
|
|
219
|
+
if not result:
|
|
220
|
+
raise AuthorizationError("timed out waiting for the browser to come back")
|
|
221
|
+
if "error" in result:
|
|
222
|
+
raise AuthorizationError(result.get("error_description") or result["error"])
|
|
223
|
+
if result.get("state") != state:
|
|
224
|
+
# Not a formality: without it any page on this machine could hand us a code.
|
|
225
|
+
raise AuthorizationError("state did not match the request")
|
|
226
|
+
if not result.get("code"):
|
|
227
|
+
raise AuthorizationError("authorization response carried no code")
|
|
228
|
+
|
|
229
|
+
print("Exchanging authorization code for tokens")
|
|
230
|
+
http_client = client or httpx.Client(timeout=30)
|
|
231
|
+
response = http_client.post(
|
|
232
|
+
endpoints.token,
|
|
233
|
+
data={
|
|
234
|
+
"grant_type": "authorization_code",
|
|
235
|
+
"code": result["code"],
|
|
236
|
+
"redirect_uri": redirect_uri,
|
|
237
|
+
"client_id": client_id,
|
|
238
|
+
"code_verifier": verifier,
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
if response.status_code != 200:
|
|
242
|
+
raise AuthorizationError(_describe(response))
|
|
243
|
+
return _tokens_from(response.json())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class DeviceChallenge:
|
|
248
|
+
"""What the human has to be shown, and how long they have."""
|
|
249
|
+
|
|
250
|
+
user_code: str
|
|
251
|
+
verification_uri: str
|
|
252
|
+
verification_uri_complete: str | None
|
|
253
|
+
expires_in: int
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def start_device(
|
|
257
|
+
endpoints: Endpoints,
|
|
258
|
+
client_id: str,
|
|
259
|
+
*,
|
|
260
|
+
scope: str = "openid profile email offline_access",
|
|
261
|
+
client: httpx.Client | None = None,
|
|
262
|
+
) -> tuple[str, DeviceChallenge, int]:
|
|
263
|
+
if not endpoints.device_authorization:
|
|
264
|
+
raise AuthorizationError(
|
|
265
|
+
"this provider publishes no device_authorization_endpoint; use the default browser flow"
|
|
266
|
+
)
|
|
267
|
+
http_client = client or httpx.Client(timeout=30)
|
|
268
|
+
response = http_client.post(
|
|
269
|
+
endpoints.device_authorization, data={"client_id": client_id, "scope": scope}
|
|
270
|
+
)
|
|
271
|
+
if response.status_code != 200:
|
|
272
|
+
raise AuthorizationError(_describe(response))
|
|
273
|
+
payload = response.json()
|
|
274
|
+
challenge = DeviceChallenge(
|
|
275
|
+
user_code=payload["user_code"],
|
|
276
|
+
verification_uri=payload["verification_uri"],
|
|
277
|
+
verification_uri_complete=payload.get("verification_uri_complete"),
|
|
278
|
+
expires_in=int(payload.get("expires_in", 600)),
|
|
279
|
+
)
|
|
280
|
+
return payload["device_code"], challenge, int(payload.get("interval", DEFAULT_INTERVAL))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def poll_device(
|
|
284
|
+
endpoints: Endpoints,
|
|
285
|
+
client_id: str,
|
|
286
|
+
device_code: str,
|
|
287
|
+
*,
|
|
288
|
+
interval: int = DEFAULT_INTERVAL,
|
|
289
|
+
expires_in: int = 600,
|
|
290
|
+
client: httpx.Client | None = None,
|
|
291
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
292
|
+
) -> Tokens:
|
|
293
|
+
"""Poll until the human decides, honouring what the provider asks for.
|
|
294
|
+
|
|
295
|
+
`slow_down` adds to the interval and is not a retry-immediately (RFC 8628 §3.5);
|
|
296
|
+
ignoring it is how a client gets rate-limited into failing a flow that would
|
|
297
|
+
otherwise have worked.
|
|
298
|
+
"""
|
|
299
|
+
http_client = client or httpx.Client(timeout=30)
|
|
300
|
+
deadline = time.time() + expires_in
|
|
301
|
+
|
|
302
|
+
while True:
|
|
303
|
+
if time.time() >= deadline:
|
|
304
|
+
raise AuthorizationError("the device code expired before it was approved")
|
|
305
|
+
sleep(interval)
|
|
306
|
+
response = http_client.post(
|
|
307
|
+
endpoints.token,
|
|
308
|
+
data={"grant_type": DEVICE_GRANT, "device_code": device_code, "client_id": client_id},
|
|
309
|
+
)
|
|
310
|
+
if response.status_code == 200:
|
|
311
|
+
return _tokens_from(response.json())
|
|
312
|
+
|
|
313
|
+
error = _error_of(response)
|
|
314
|
+
if error == "authorization_pending":
|
|
315
|
+
continue
|
|
316
|
+
if error == "slow_down":
|
|
317
|
+
interval += SLOW_DOWN_INCREMENT
|
|
318
|
+
continue
|
|
319
|
+
if error == "access_denied":
|
|
320
|
+
raise AuthorizationError("the request was denied")
|
|
321
|
+
if error == "expired_token":
|
|
322
|
+
raise AuthorizationError("the device code expired before it was approved")
|
|
323
|
+
raise AuthorizationError(_describe(response))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def refresh(
|
|
327
|
+
endpoints: Endpoints,
|
|
328
|
+
client_id: str,
|
|
329
|
+
refresh_token: str,
|
|
330
|
+
*,
|
|
331
|
+
client: httpx.Client | None = None,
|
|
332
|
+
) -> Tokens:
|
|
333
|
+
"""Exchange a refresh token, and expect a new one back.
|
|
334
|
+
|
|
335
|
+
Rotation is the provider's job; ours is to store what comes back rather than
|
|
336
|
+
keeping the token we sent. A client that keeps the old one turns the provider's
|
|
337
|
+
single-use enforcement into a login loop the day it is switched on.
|
|
338
|
+
"""
|
|
339
|
+
http_client = client or httpx.Client(timeout=30)
|
|
340
|
+
response = http_client.post(
|
|
341
|
+
endpoints.token,
|
|
342
|
+
data={
|
|
343
|
+
"grant_type": "refresh_token",
|
|
344
|
+
"refresh_token": refresh_token,
|
|
345
|
+
"client_id": client_id,
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
if response.status_code != 200:
|
|
349
|
+
raise AuthorizationError(_describe(response))
|
|
350
|
+
payload = response.json()
|
|
351
|
+
tokens = _tokens_from(payload)
|
|
352
|
+
if tokens.refresh_token is None:
|
|
353
|
+
# The provider rotated nothing; keep what still works rather than losing it.
|
|
354
|
+
tokens = Tokens(
|
|
355
|
+
access_token=tokens.access_token,
|
|
356
|
+
refresh_token=refresh_token,
|
|
357
|
+
expires_at=tokens.expires_at,
|
|
358
|
+
scope=tokens.scope,
|
|
359
|
+
)
|
|
360
|
+
return tokens
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def revoke(
|
|
364
|
+
endpoints: Endpoints,
|
|
365
|
+
client_id: str,
|
|
366
|
+
token: str,
|
|
367
|
+
*,
|
|
368
|
+
client: httpx.Client | None = None,
|
|
369
|
+
) -> bool:
|
|
370
|
+
"""Tell the provider to forget this device's token.
|
|
371
|
+
|
|
372
|
+
"Revocable per device" (ADR 0004) is only true if logging out actually revokes.
|
|
373
|
+
Deleting the local copy would leave a live refresh token behind on a laptop
|
|
374
|
+
somebody just handed back.
|
|
375
|
+
"""
|
|
376
|
+
if not endpoints.revocation:
|
|
377
|
+
return False
|
|
378
|
+
http_client = client or httpx.Client(timeout=30)
|
|
379
|
+
response = http_client.post(
|
|
380
|
+
endpoints.revocation,
|
|
381
|
+
data={"token": token, "token_type_hint": "refresh_token", "client_id": client_id},
|
|
382
|
+
)
|
|
383
|
+
return response.status_code in (200, 204)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _error_of(response: httpx.Response) -> str | None:
|
|
387
|
+
try:
|
|
388
|
+
return response.json().get("error")
|
|
389
|
+
except ValueError:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _describe(response: httpx.Response) -> str:
|
|
394
|
+
try:
|
|
395
|
+
payload = response.json()
|
|
396
|
+
return payload.get("error_description") or payload.get("error") or response.text
|
|
397
|
+
except ValueError:
|
|
398
|
+
return f"HTTP {response.status_code}"
|
mkctl/py.typed
ADDED
|
File without changes
|
mkctl/session.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Holding a login: acquiring, refreshing, and giving it up.
|
|
2
|
+
|
|
3
|
+
The one rule worth stating: whatever comes back from a refresh is what gets stored.
|
|
4
|
+
Providers rotate refresh tokens and enforce single use, so a client that keeps the
|
|
5
|
+
token it just sent turns that enforcement into a login loop on the day it is switched
|
|
6
|
+
on — and the failure looks like the provider misbehaving.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from . import oauth
|
|
16
|
+
from .oauth import AuthorizationError, Endpoints, Tokens
|
|
17
|
+
from .store import TokenStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Session:
|
|
22
|
+
control_plane: str
|
|
23
|
+
issuer: str
|
|
24
|
+
client_id: str
|
|
25
|
+
store: TokenStore
|
|
26
|
+
client: httpx.Client | None = None
|
|
27
|
+
|
|
28
|
+
def endpoints(self) -> Endpoints:
|
|
29
|
+
return Endpoints.discover(self.issuer, client=self.client)
|
|
30
|
+
|
|
31
|
+
def login(self, *, device: bool = False, open_browser: bool = True) -> Tokens:
|
|
32
|
+
"""Acquire a login and store it.
|
|
33
|
+
|
|
34
|
+
The browser flow is the default because it is what the native-app BCP asks
|
|
35
|
+
for when a browser is present. `device=True` is for when it is not, which is
|
|
36
|
+
every terminal reached over SSH.
|
|
37
|
+
"""
|
|
38
|
+
endpoints = self.endpoints()
|
|
39
|
+
if device:
|
|
40
|
+
tokens = self._device_login(endpoints)
|
|
41
|
+
else:
|
|
42
|
+
tokens = oauth.login_loopback(
|
|
43
|
+
endpoints,
|
|
44
|
+
self.client_id,
|
|
45
|
+
open_browser=open_browser,
|
|
46
|
+
client=self.client,
|
|
47
|
+
)
|
|
48
|
+
self.store.save(tokens)
|
|
49
|
+
return tokens
|
|
50
|
+
|
|
51
|
+
def _device_login(self, endpoints: Endpoints) -> Tokens:
|
|
52
|
+
device_code, challenge, interval = oauth.start_device(
|
|
53
|
+
endpoints, self.client_id, client=self.client
|
|
54
|
+
)
|
|
55
|
+
target = challenge.verification_uri_complete or challenge.verification_uri
|
|
56
|
+
print(f"Open {target}")
|
|
57
|
+
if not challenge.verification_uri_complete:
|
|
58
|
+
print(f"and enter the code: {challenge.user_code}")
|
|
59
|
+
return oauth.poll_device(
|
|
60
|
+
endpoints,
|
|
61
|
+
self.client_id,
|
|
62
|
+
device_code,
|
|
63
|
+
interval=interval,
|
|
64
|
+
expires_in=challenge.expires_in,
|
|
65
|
+
client=self.client,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def access_token(self) -> str:
|
|
69
|
+
"""A usable access token, refreshed if it is due.
|
|
70
|
+
|
|
71
|
+
Raises rather than silently returning nothing: a caller that gets `None` here
|
|
72
|
+
writes `Authorization: Bearer None`, and the server's answer to that is the
|
|
73
|
+
same 401 as a real credential failure — a confusing way to learn you are
|
|
74
|
+
logged out.
|
|
75
|
+
"""
|
|
76
|
+
tokens = self.store.load()
|
|
77
|
+
if tokens is None:
|
|
78
|
+
raise AuthorizationError("not logged in — run `mkctl login`")
|
|
79
|
+
if not tokens.expired:
|
|
80
|
+
return tokens.access_token
|
|
81
|
+
if not tokens.refresh_token:
|
|
82
|
+
raise AuthorizationError("the session expired and cannot be refreshed — log in again")
|
|
83
|
+
|
|
84
|
+
refreshed = oauth.refresh(
|
|
85
|
+
self.endpoints(), self.client_id, tokens.refresh_token, client=self.client
|
|
86
|
+
)
|
|
87
|
+
self.store.save(refreshed)
|
|
88
|
+
return refreshed.access_token
|
|
89
|
+
|
|
90
|
+
def logout(self) -> bool:
|
|
91
|
+
"""Revoke at the provider, then forget locally.
|
|
92
|
+
|
|
93
|
+
In that order, and both. Deleting only the local copy leaves a live refresh
|
|
94
|
+
token on a machine somebody may have just handed back, which is the opposite
|
|
95
|
+
of "revocable per device".
|
|
96
|
+
"""
|
|
97
|
+
tokens = self.store.load()
|
|
98
|
+
revoked = False
|
|
99
|
+
if tokens and tokens.refresh_token:
|
|
100
|
+
revoked = oauth.revoke(
|
|
101
|
+
self.endpoints(), self.client_id, tokens.refresh_token, client=self.client
|
|
102
|
+
)
|
|
103
|
+
self.store.clear()
|
|
104
|
+
return revoked
|
mkctl/store.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Where the tokens live.
|
|
2
|
+
|
|
3
|
+
> Tokens in the OS keyring — ADR 0004
|
|
4
|
+
|
|
5
|
+
So: the OS keyring, and no second place. If there is no secure backend, this raises
|
|
6
|
+
and says what to do about it rather than quietly writing a file. A CLI that degrades
|
|
7
|
+
to plaintext when the keyring is missing is `nate`'s `TOKEN_ENCRYPTION_KEY` again —
|
|
8
|
+
the safe path must not be the one that happens to be configured.
|
|
9
|
+
|
|
10
|
+
One entry per control plane, keyed by its URL, so a laptop that talks to production
|
|
11
|
+
and to a local stack does not have one token overwrite the other.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from .oauth import Tokens
|
|
21
|
+
|
|
22
|
+
SERVICE = "mkctl"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class KeyringBackend(Protocol):
|
|
26
|
+
# Positional-only, because the real keyring backend decorates `set_password`
|
|
27
|
+
# into a wrapper whose parameter names are not the interface's.
|
|
28
|
+
def get_password(self, service: str, username: str, /) -> str | None: ...
|
|
29
|
+
def set_password(self, service: str, username: str, password: str, /) -> None: ...
|
|
30
|
+
def delete_password(self, service: str, username: str, /) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StoreUnavailable(Exception):
|
|
34
|
+
"""There is no secure place to put a token on this machine."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TokenStore:
|
|
39
|
+
control_plane: str
|
|
40
|
+
backend: KeyringBackend | None = None
|
|
41
|
+
|
|
42
|
+
def _keyring(self) -> KeyringBackend:
|
|
43
|
+
if self.backend is not None:
|
|
44
|
+
return self.backend
|
|
45
|
+
import keyring
|
|
46
|
+
from keyring.backends.fail import Keyring as FailKeyring
|
|
47
|
+
|
|
48
|
+
backend = keyring.get_keyring()
|
|
49
|
+
if isinstance(backend, FailKeyring):
|
|
50
|
+
raise StoreUnavailable(
|
|
51
|
+
"no OS keyring is available on this machine, and mkctl does not write "
|
|
52
|
+
"tokens to disk (ADR 0004). Install and start a Secret Service provider "
|
|
53
|
+
"— gnome-keyring or kwallet on Linux — and try again"
|
|
54
|
+
)
|
|
55
|
+
return backend
|
|
56
|
+
|
|
57
|
+
def load(self) -> Tokens | None:
|
|
58
|
+
raw = self._keyring().get_password(SERVICE, self.control_plane)
|
|
59
|
+
if not raw:
|
|
60
|
+
return None
|
|
61
|
+
payload = json.loads(raw)
|
|
62
|
+
return Tokens(
|
|
63
|
+
access_token=payload["access_token"],
|
|
64
|
+
refresh_token=payload.get("refresh_token"),
|
|
65
|
+
expires_at=payload["expires_at"],
|
|
66
|
+
scope=payload.get("scope"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def save(self, tokens: Tokens) -> None:
|
|
70
|
+
self._keyring().set_password(
|
|
71
|
+
SERVICE,
|
|
72
|
+
self.control_plane,
|
|
73
|
+
json.dumps(
|
|
74
|
+
{
|
|
75
|
+
"access_token": tokens.access_token,
|
|
76
|
+
"refresh_token": tokens.refresh_token,
|
|
77
|
+
"expires_at": tokens.expires_at,
|
|
78
|
+
"scope": tokens.scope,
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
# Broad on purpose: backends raise different things for "no such entry", and
|
|
85
|
+
# for a logout that is success. The only outcome that matters is that nothing
|
|
86
|
+
# is left behind.
|
|
87
|
+
try:
|
|
88
|
+
self._keyring().delete_password(SERVICE, self.control_plane)
|
|
89
|
+
except Exception:
|
|
90
|
+
return
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mkctl
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: The command line for the control plane (ADR 0004)
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: <3.14,>=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: keyring>=25.0
|
|
10
|
+
Requires-Dist: mkp-contracts>=0.2.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mkctl/__init__.py,sha256=NJPyKC8AaLjOktbJ-9kQgXY8u8xKve4fUscqEyHVONo,305
|
|
2
|
+
mkctl/cli.py,sha256=5rjpDuSP1KAwYIt35nf2ZXKIXJWa4ITRAu0DVID3Zts,20212
|
|
3
|
+
mkctl/client.py,sha256=uCsfjRK9p3tbtoSmvowC7iIlSEuj0NYpxshUqZ1nErI,5219
|
|
4
|
+
mkctl/commands.py,sha256=RjFcBootuUzL4iNvJ2_f7pZGs5pGZMpmMlfg7m44CY4,21040
|
|
5
|
+
mkctl/config.py,sha256=84wZRwn9w2In_zFtPytyPy3p_mDUYjyxo4qgjRsiQRQ,2251
|
|
6
|
+
mkctl/oauth.py,sha256=CTnsaTp0HOrLZ-kiomzJqcMZDlNK4st_On1K-7bj9bY,14078
|
|
7
|
+
mkctl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
mkctl/session.py,sha256=J4sj2T1nNw_0DDK5HMuo8-KUYb5YZpxz5oArIJsFnTk,3724
|
|
9
|
+
mkctl/store.py,sha256=Gsrw4nrI3cCKpv3ylluY-YyQUMIN6bPnOr-i5Yc67nY,3138
|
|
10
|
+
mkctl-0.4.0.dist-info/METADATA,sha256=qUB3k2iRzHGilcGyOGeo2AsEPQcGJyTV733pyMZ3K-8,283
|
|
11
|
+
mkctl-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
mkctl-0.4.0.dist-info/entry_points.txt,sha256=6eZOnaohy6orNkfGb_1ZjQyrOkZjJ4Y-pfYkX3yNjv4,41
|
|
13
|
+
mkctl-0.4.0.dist-info/licenses/LICENSE,sha256=OomESGL67ZXotKqNbTv84ga2xAp3zho0QQH_kjVDTxQ,11338
|
|
14
|
+
mkctl-0.4.0.dist-info/RECORD,,
|