ddpsrun 0.1.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.
- ddpsrun/__init__.py +7 -0
- ddpsrun/browser_login.py +281 -0
- ddpsrun/cli.py +827 -0
- ddpsrun/client.py +200 -0
- ddpsrun/config.py +162 -0
- ddpsrun-0.1.0.dist-info/METADATA +182 -0
- ddpsrun-0.1.0.dist-info/RECORD +11 -0
- ddpsrun-0.1.0.dist-info/WHEEL +5 -0
- ddpsrun-0.1.0.dist-info/entry_points.txt +2 -0
- ddpsrun-0.1.0.dist-info/licenses/LICENSE +202 -0
- ddpsrun-0.1.0.dist-info/top_level.txt +1 -0
ddpsrun/__init__.py
ADDED
ddpsrun/browser_login.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Sign in through a browser, the way `aws sso login` does.
|
|
2
|
+
|
|
3
|
+
END-TO-END FLOW of this file:
|
|
4
|
+
|
|
5
|
+
1. `login()` asks the server where its login page is (`GET /v1/login-config`).
|
|
6
|
+
A server with no user pool answers `enabled: false` and the caller falls
|
|
7
|
+
back to `--token`.
|
|
8
|
+
2. It picks a free port from the registered list, and starts a one-request
|
|
9
|
+
HTTP server on it. That server exists only to catch one redirect.
|
|
10
|
+
3. It generates the PKCE pair, opens the browser at Cognito's authorize URL,
|
|
11
|
+
and blocks.
|
|
12
|
+
4. The person signs in. Cognito sends their browser to
|
|
13
|
+
http://localhost:<port>/callback?code=... The server in step 2 takes the
|
|
14
|
+
code, writes a page saying the tab can be closed, and stops.
|
|
15
|
+
5. The code plus the PKCE verifier are exchanged for an id_token and a
|
|
16
|
+
refresh_token, which the caller stores.
|
|
17
|
+
|
|
18
|
+
WHY A LOCAL HTTP SERVER AT ALL. Cognito never hands the authorization code to a
|
|
19
|
+
person; it puts it in a redirect and the browser follows it. In a browser app
|
|
20
|
+
that redirect goes to the page's own URL. A CLI has no URL, so it makes one:
|
|
21
|
+
`http://localhost:<port>`, which is the only address a browser on the user's own
|
|
22
|
+
machine can reach that the CLI also controls (`docs/16-login.md` 16.4).
|
|
23
|
+
|
|
24
|
+
WHY THE PORT COMES FROM A LIST. Cognito compares `redirect_uri` against its
|
|
25
|
+
registered callback URLs character for character. A port nobody registered
|
|
26
|
+
cannot receive the code, so the CLI can only use ports the operator put in
|
|
27
|
+
`terraform/cognito`'s `callback_urls`.
|
|
28
|
+
|
|
29
|
+
WHY PKCE. Exchanging a code normally needs a client secret, and a CLI on a
|
|
30
|
+
laptop cannot hold one. PKCE replaces it with a random number this process
|
|
31
|
+
generates, keeps, and only reveals at the exchange.
|
|
32
|
+
|
|
33
|
+
Grep anchor: DDPSRUN-CLI-LOGIN
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import base64
|
|
39
|
+
import hashlib
|
|
40
|
+
import http.server
|
|
41
|
+
import json
|
|
42
|
+
import secrets
|
|
43
|
+
import socket
|
|
44
|
+
import threading
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
47
|
+
import urllib.request
|
|
48
|
+
import webbrowser
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
|
|
51
|
+
# The ports `terraform/cognito` registers. Keep the two lists identical: a port
|
|
52
|
+
# here that is not registered there fails with redirect_mismatch, and a port
|
|
53
|
+
# registered there but missing here is simply never used.
|
|
54
|
+
CANDIDATE_PORTS = (51234, 51235, 51236, 51237)
|
|
55
|
+
|
|
56
|
+
HOW_LONG_TO_WAIT_SECONDS = 300
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LoginError(Exception):
|
|
60
|
+
"""Signing in did not finish. The CLI prints this and exits non-zero."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Tokens:
|
|
65
|
+
"""What a completed sign-in produced.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
id_token: the JWT sent as `Authorization: Bearer`. Lives an hour.
|
|
69
|
+
refresh_token: buys a new id_token without another browser trip. Lives
|
|
70
|
+
30 days, which is why it is the thing worth storing.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id_token: str
|
|
74
|
+
refresh_token: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _b64(raw: bytes) -> str:
|
|
78
|
+
"""base64url with the padding stripped, which is what OAuth asks for."""
|
|
79
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pkce() -> tuple[str, str]:
|
|
83
|
+
"""Make the PKCE pair.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
`(verifier, challenge)`. The verifier never leaves this process until
|
|
87
|
+
the exchange; the challenge is its SHA-256 and is safe to send first.
|
|
88
|
+
"""
|
|
89
|
+
verifier = _b64(secrets.token_bytes(32))
|
|
90
|
+
challenge = _b64(hashlib.sha256(verifier.encode()).digest())
|
|
91
|
+
return verifier, challenge
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _free_port() -> int:
|
|
95
|
+
"""The first registered port nothing else is listening on.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A port number from CANDIDATE_PORTS.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
LoginError: every registered port is busy. Naming them is the useful
|
|
102
|
+
part of the message: the fix is to stop whatever holds one.
|
|
103
|
+
"""
|
|
104
|
+
for port in CANDIDATE_PORTS:
|
|
105
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
106
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
107
|
+
try:
|
|
108
|
+
probe.bind(("127.0.0.1", port))
|
|
109
|
+
return port
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
listed = ", ".join(str(p) for p in CANDIDATE_PORTS)
|
|
113
|
+
raise LoginError(
|
|
114
|
+
f"every port this command may use is busy ({listed}). "
|
|
115
|
+
f"Close whatever is listening on one of them and try again."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
DONE_PAGE = b"""<!doctype html><meta charset="utf-8">
|
|
120
|
+
<title>ddpsrun</title>
|
|
121
|
+
<body style="font-family:system-ui;padding:64px;text-align:center">
|
|
122
|
+
<h2>Signed in.</h2><p>You can close this tab and go back to the terminal.</p>
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
FAILED_PAGE = b"""<!doctype html><meta charset="utf-8">
|
|
126
|
+
<title>ddpsrun</title>
|
|
127
|
+
<body style="font-family:system-ui;padding:64px;text-align:center">
|
|
128
|
+
<h2>Sign-in did not complete.</h2><p>The terminal has the details.</p>
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Catcher(http.server.BaseHTTPRequestHandler):
|
|
133
|
+
"""Answers exactly one GET and records what came with it."""
|
|
134
|
+
|
|
135
|
+
code: str | None = None
|
|
136
|
+
error: str | None = None
|
|
137
|
+
|
|
138
|
+
def do_GET(self) -> None: # noqa: N802 (the name is http.server's)
|
|
139
|
+
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
|
140
|
+
_Catcher.code = (query.get("code") or [None])[0]
|
|
141
|
+
_Catcher.error = (query.get("error_description") or query.get("error") or [None])[0]
|
|
142
|
+
body = DONE_PAGE if _Catcher.code else FAILED_PAGE
|
|
143
|
+
self.send_response(200)
|
|
144
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
145
|
+
self.send_header("Content-Length", str(len(body)))
|
|
146
|
+
self.end_headers()
|
|
147
|
+
self.wfile.write(body)
|
|
148
|
+
|
|
149
|
+
def log_message(self, *args: object) -> None:
|
|
150
|
+
"""Silence. The one line this server would print is noise in a CLI."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def login_config(server: str, opener=urllib.request.urlopen) -> dict:
|
|
154
|
+
"""Ask the server whether Cognito is configured and where its login page is.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
server: the gateway URL.
|
|
158
|
+
opener: injected so tests do not need a network.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The parsed body. `{"enabled": False}` when the server has no user pool,
|
|
162
|
+
and also when it is too old to know this route at all.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
with opener(f"{server.rstrip('/')}/v1/login-config", timeout=30) as response:
|
|
166
|
+
return json.loads(response.read().decode())
|
|
167
|
+
except urllib.error.HTTPError as exc:
|
|
168
|
+
if exc.code == 404:
|
|
169
|
+
return {"enabled": False}
|
|
170
|
+
raise LoginError(f"the server answered {exc.code} when asked how to sign in") from exc
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
raise LoginError(f"could not reach {server}: {exc}") from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def exchange(config: dict, form: dict, opener=urllib.request.urlopen) -> Tokens:
|
|
176
|
+
"""Trade something for tokens at Cognito's token endpoint.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
config: what `login_config` returned.
|
|
180
|
+
form: the grant. Either an authorization_code with its verifier, or a
|
|
181
|
+
refresh_token.
|
|
182
|
+
opener: injected for tests.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The tokens. On a refresh Cognito returns no new refresh_token, so that
|
|
186
|
+
field comes back empty and the caller keeps the one it already has.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
LoginError: Cognito refused, with its own message where it gave one.
|
|
190
|
+
"""
|
|
191
|
+
body = urllib.parse.urlencode({"client_id": config["client_id"], **form}).encode()
|
|
192
|
+
request = urllib.request.Request(
|
|
193
|
+
f"{config['login_domain']}/oauth2/token",
|
|
194
|
+
data=body,
|
|
195
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
with opener(request, timeout=30) as response:
|
|
199
|
+
parsed = json.loads(response.read().decode())
|
|
200
|
+
except urllib.error.HTTPError as exc:
|
|
201
|
+
detail = ""
|
|
202
|
+
try:
|
|
203
|
+
payload = json.loads(exc.read().decode())
|
|
204
|
+
detail = payload.get("error_description") or payload.get("error") or ""
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
raise LoginError(f"Cognito refused the exchange: {detail or exc.code}") from exc
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
raise LoginError(f"could not reach Cognito: {exc}") from exc
|
|
210
|
+
|
|
211
|
+
return Tokens(
|
|
212
|
+
id_token=parsed.get("id_token", ""),
|
|
213
|
+
refresh_token=parsed.get("refresh_token", ""),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def login(server: str, open_browser=webbrowser.open) -> Tokens:
|
|
218
|
+
"""Run the whole browser round trip and return the tokens.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
server: the gateway URL.
|
|
222
|
+
open_browser: injected so a test can assert on the URL instead of
|
|
223
|
+
actually opening a browser.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The tokens.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
LoginError: the server has no Cognito, every port is busy, the person
|
|
230
|
+
closed the browser, or Cognito refused.
|
|
231
|
+
"""
|
|
232
|
+
config = login_config(server)
|
|
233
|
+
if not config.get("enabled"):
|
|
234
|
+
raise LoginError(
|
|
235
|
+
"this server does not have browser sign-in configured. "
|
|
236
|
+
"Use `ddpsrun login --server ... --token ...` instead."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
port = _free_port()
|
|
240
|
+
redirect_uri = f"http://localhost:{port}/callback"
|
|
241
|
+
verifier, challenge = _pkce()
|
|
242
|
+
|
|
243
|
+
_Catcher.code = None
|
|
244
|
+
_Catcher.error = None
|
|
245
|
+
httpd = http.server.HTTPServer(("127.0.0.1", port), _Catcher)
|
|
246
|
+
# One request and then stop. A server that kept listening would be a port
|
|
247
|
+
# left open on the user's machine for as long as the process lived.
|
|
248
|
+
thread = threading.Thread(target=httpd.handle_request, daemon=True)
|
|
249
|
+
thread.start()
|
|
250
|
+
|
|
251
|
+
query = urllib.parse.urlencode({
|
|
252
|
+
"client_id": config["client_id"],
|
|
253
|
+
"response_type": "code",
|
|
254
|
+
"scope": " ".join(config.get("scopes", ["openid", "email"])),
|
|
255
|
+
"redirect_uri": redirect_uri,
|
|
256
|
+
"code_challenge": challenge,
|
|
257
|
+
"code_challenge_method": "S256",
|
|
258
|
+
})
|
|
259
|
+
authorize_url = f"{config['login_domain']}/oauth2/authorize?{query}"
|
|
260
|
+
|
|
261
|
+
print("Opening your browser to sign in.")
|
|
262
|
+
print(f"If it did not open, go to:\n {authorize_url}\n")
|
|
263
|
+
open_browser(authorize_url)
|
|
264
|
+
|
|
265
|
+
thread.join(timeout=HOW_LONG_TO_WAIT_SECONDS)
|
|
266
|
+
httpd.server_close()
|
|
267
|
+
|
|
268
|
+
if _Catcher.error:
|
|
269
|
+
raise LoginError(f"sign-in was refused: {_Catcher.error}")
|
|
270
|
+
if not _Catcher.code:
|
|
271
|
+
raise LoginError(
|
|
272
|
+
f"nothing came back within {HOW_LONG_TO_WAIT_SECONDS} seconds. "
|
|
273
|
+
f"The browser tab has to finish before this command can."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return exchange(config, {
|
|
277
|
+
"grant_type": "authorization_code",
|
|
278
|
+
"code": _Catcher.code,
|
|
279
|
+
"redirect_uri": redirect_uri,
|
|
280
|
+
"code_verifier": verifier,
|
|
281
|
+
})
|