cli-login 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.
- cli_login/__init__.py +4 -0
- cli_login/broker/__init__.py +0 -0
- cli_login/broker/handler.py +310 -0
- cli_login/broker/pages.py +175 -0
- cli_login/broker/throttle.py +25 -0
- cli_login/broker/transactions.py +201 -0
- cli_login/errors.py +29 -0
- cli_login/locks.py +111 -0
- cli_login/oauth.py +143 -0
- cli_login/remote.py +208 -0
- cli_login/session.py +213 -0
- cli_login/stores/__init__.py +122 -0
- cli_login/stores/agent.py +466 -0
- cli_login/stores/kernel_keyring.py +202 -0
- cli_login/stores/macos.py +177 -0
- cli_login/stores/secret_service.py +112 -0
- cli_login/stores/windows.py +211 -0
- cli_login/target.py +101 -0
- cli_login/testing.py +258 -0
- cli_login-0.1.0.dist-info/METADATA +104 -0
- cli_login-0.1.0.dist-info/RECORD +23 -0
- cli_login-0.1.0.dist-info/WHEEL +4 -0
- cli_login-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Remote sign-in transactions: create once, change state conditionally, hand the code over exactly once.
|
|
2
|
+
|
|
3
|
+
Two stores share one interface: ``MemoryTransactions`` for specs and local runs, and ``DynamoTransactions`` for the
|
|
4
|
+
deployed broker, whose table expires items through TTL on ``expires_at``. Every change is conditional on the state
|
|
5
|
+
it expects, so two callers racing for the same transition cannot both win.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
PENDING, BOUND, APPROVED, DENIED = "pending", "bound", "approved", "denied"
|
|
16
|
+
STATES = (PENDING, BOUND, APPROVED, DENIED)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MemoryTransactions:
|
|
20
|
+
"""In-process store with the same conditional semantics as the DynamoDB one."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._items: dict[str, dict[str, Any]] = {}
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
def create(self, item: Mapping[str, Any]) -> bool:
|
|
27
|
+
with self._lock:
|
|
28
|
+
if item["id"] in self._items:
|
|
29
|
+
return False
|
|
30
|
+
self._items[item["id"]] = dict(item)
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def get(self, transaction_id: str) -> dict[str, Any] | None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
item = self._items.get(transaction_id)
|
|
36
|
+
return copy.deepcopy(item) if item else None
|
|
37
|
+
|
|
38
|
+
def transition(
|
|
39
|
+
self,
|
|
40
|
+
transaction_id: str,
|
|
41
|
+
from_states: Iterable[str],
|
|
42
|
+
changes: Mapping[str, Any],
|
|
43
|
+
*,
|
|
44
|
+
expect: Mapping[str, Any] | None = None,
|
|
45
|
+
) -> dict[str, Any] | None:
|
|
46
|
+
"""Apply ``changes`` only when the state is one of ``from_states`` and every ``expect`` field matches."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
item = self._items.get(transaction_id)
|
|
49
|
+
if item is None or item["state"] not in tuple(from_states):
|
|
50
|
+
return None
|
|
51
|
+
if any(item.get(key) != value for key, value in (expect or {}).items()):
|
|
52
|
+
return None
|
|
53
|
+
item.update(changes)
|
|
54
|
+
return copy.deepcopy(item)
|
|
55
|
+
|
|
56
|
+
def take(self, transaction_id: str) -> dict[str, Any] | None:
|
|
57
|
+
"""Delete an approved transaction and return it; None when it is not approved or another caller took it."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
item = self._items.get(transaction_id)
|
|
60
|
+
if item is None or item["state"] != APPROVED or not item.get("code"):
|
|
61
|
+
return None
|
|
62
|
+
return self._items.pop(transaction_id)
|
|
63
|
+
|
|
64
|
+
def count(self, key: str, now: int, window_seconds: int) -> int:
|
|
65
|
+
"""Count one call against ``key`` in the current window and return the window's total."""
|
|
66
|
+
window = now // window_seconds
|
|
67
|
+
with self._lock:
|
|
68
|
+
counter = self._items.setdefault(f"rate#{key}#{window}", {"count": 0})
|
|
69
|
+
counter["count"] += 1
|
|
70
|
+
return counter["count"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _failed_condition(error: Exception) -> bool:
|
|
74
|
+
# The client's ConditionalCheckFailedException, recognised by its code so no AWS import is needed.
|
|
75
|
+
response = getattr(error, "response", None) or {}
|
|
76
|
+
return (response.get("Error") or {}).get("Code") == "ConditionalCheckFailedException"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _encode(value: Any) -> dict[str, Any]:
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return {"BOOL": value}
|
|
82
|
+
if isinstance(value, int):
|
|
83
|
+
return {"N": str(value)}
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
return {"S": value}
|
|
86
|
+
if value is None:
|
|
87
|
+
return {"NULL": True}
|
|
88
|
+
if isinstance(value, Mapping):
|
|
89
|
+
return {"M": {key: _encode(item) for key, item in value.items()}}
|
|
90
|
+
raise TypeError(f"unsupported attribute type {type(value).__name__}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _decode(attribute: Mapping[str, Any]) -> Any:
|
|
94
|
+
(kind, value), = attribute.items()
|
|
95
|
+
if kind == "N":
|
|
96
|
+
return int(value)
|
|
97
|
+
if kind == "M":
|
|
98
|
+
return {key: _decode(item) for key, item in value.items()}
|
|
99
|
+
if kind == "NULL":
|
|
100
|
+
return None
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _item(raw: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
105
|
+
if not raw:
|
|
106
|
+
return None
|
|
107
|
+
return {key: _decode(value) for key, value in raw.items() if key != "pk"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class DynamoTransactions:
|
|
111
|
+
"""The deployed store: one table keyed by ``pk``, with TTL on ``expires_at``.
|
|
112
|
+
|
|
113
|
+
``client`` is a low-level DynamoDB client (``boto3.client("dynamodb")``); specs pass a recording fake.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, client, table: str):
|
|
117
|
+
self.client = client
|
|
118
|
+
self.table = table
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _key(transaction_id: str) -> dict[str, Any]:
|
|
122
|
+
return {"pk": {"S": "tx#" + transaction_id}}
|
|
123
|
+
|
|
124
|
+
def create(self, item: Mapping[str, Any]) -> bool:
|
|
125
|
+
try:
|
|
126
|
+
self.client.put_item(
|
|
127
|
+
TableName=self.table,
|
|
128
|
+
Item={**self._key(item["id"]), **{key: _encode(value) for key, value in item.items()}},
|
|
129
|
+
ConditionExpression="attribute_not_exists(pk)",
|
|
130
|
+
)
|
|
131
|
+
return True
|
|
132
|
+
except Exception as error:
|
|
133
|
+
if _failed_condition(error):
|
|
134
|
+
return False
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
def get(self, transaction_id: str) -> dict[str, Any] | None:
|
|
138
|
+
result = self.client.get_item(TableName=self.table, Key=self._key(transaction_id), ConsistentRead=True)
|
|
139
|
+
return _item(result.get("Item"))
|
|
140
|
+
|
|
141
|
+
def transition(self, transaction_id, from_states, changes, *, expect=None):
|
|
142
|
+
names, values, sets = {"#state": "state"}, {}, []
|
|
143
|
+
for index, (key, value) in enumerate(changes.items()):
|
|
144
|
+
names[f"#c{index}"] = key
|
|
145
|
+
values[f":c{index}"] = _encode(value)
|
|
146
|
+
sets.append(f"#c{index} = :c{index}")
|
|
147
|
+
allowed = []
|
|
148
|
+
for index, state in enumerate(from_states):
|
|
149
|
+
values[f":s{index}"] = _encode(state)
|
|
150
|
+
allowed.append(f":s{index}")
|
|
151
|
+
conditions = [f"#state IN ({', '.join(allowed)})"]
|
|
152
|
+
for index, (key, value) in enumerate((expect or {}).items()):
|
|
153
|
+
names[f"#e{index}"] = key
|
|
154
|
+
if value is None:
|
|
155
|
+
conditions.append(f"attribute_not_exists(#e{index})")
|
|
156
|
+
else:
|
|
157
|
+
values[f":e{index}"] = _encode(value)
|
|
158
|
+
conditions.append(f"#e{index} = :e{index}")
|
|
159
|
+
try:
|
|
160
|
+
result = self.client.update_item(
|
|
161
|
+
TableName=self.table,
|
|
162
|
+
Key=self._key(transaction_id),
|
|
163
|
+
UpdateExpression="SET " + ", ".join(sets),
|
|
164
|
+
ConditionExpression="attribute_exists(pk) AND " + " AND ".join(conditions),
|
|
165
|
+
ExpressionAttributeNames=names,
|
|
166
|
+
ExpressionAttributeValues=values,
|
|
167
|
+
ReturnValues="ALL_NEW",
|
|
168
|
+
)
|
|
169
|
+
except Exception as error:
|
|
170
|
+
if _failed_condition(error):
|
|
171
|
+
return None
|
|
172
|
+
raise
|
|
173
|
+
return _item(result.get("Attributes"))
|
|
174
|
+
|
|
175
|
+
def take(self, transaction_id: str) -> dict[str, Any] | None:
|
|
176
|
+
try:
|
|
177
|
+
result = self.client.delete_item(
|
|
178
|
+
TableName=self.table,
|
|
179
|
+
Key=self._key(transaction_id),
|
|
180
|
+
ConditionExpression="#state = :approved AND attribute_exists(code)",
|
|
181
|
+
ExpressionAttributeNames={"#state": "state"},
|
|
182
|
+
ExpressionAttributeValues={":approved": _encode(APPROVED)},
|
|
183
|
+
ReturnValues="ALL_OLD",
|
|
184
|
+
)
|
|
185
|
+
except Exception as error:
|
|
186
|
+
if _failed_condition(error):
|
|
187
|
+
return None
|
|
188
|
+
raise
|
|
189
|
+
return _item(result.get("Attributes"))
|
|
190
|
+
|
|
191
|
+
def count(self, key: str, now: int, window_seconds: int) -> int:
|
|
192
|
+
window = now // window_seconds
|
|
193
|
+
result = self.client.update_item(
|
|
194
|
+
TableName=self.table,
|
|
195
|
+
Key={"pk": {"S": f"rate#{key}#{window}"}},
|
|
196
|
+
UpdateExpression="ADD #count :one SET expires_at = if_not_exists(expires_at, :expires)",
|
|
197
|
+
ExpressionAttributeNames={"#count": "count"},
|
|
198
|
+
ExpressionAttributeValues={":one": {"N": "1"}, ":expires": {"N": str((window + 2) * window_seconds)}},
|
|
199
|
+
ReturnValues="UPDATED_NEW",
|
|
200
|
+
)
|
|
201
|
+
return int(result["Attributes"]["count"]["N"])
|
cli_login/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Error types. Messages never carry token values or provider payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthError(ValueError):
|
|
7
|
+
"""A safe, operator-facing message; never include provider payloads or secrets."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StoreUnavailable(AuthError):
|
|
11
|
+
"""A secure store cannot be used here (absent, locked or blocked).
|
|
12
|
+
|
|
13
|
+
Choosing a store moves on to the next candidate; the message is the recovery guidance and never carries a
|
|
14
|
+
secret or a backend's raw text.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OAuthError(AuthError):
|
|
19
|
+
"""The provider refused or could not serve a token request."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: str, *, transient: bool = False, login_command: str | None = None):
|
|
22
|
+
self.code = code
|
|
23
|
+
self.transient = transient
|
|
24
|
+
login = login_command or "login"
|
|
25
|
+
super().__init__(
|
|
26
|
+
"Sign-in provider temporarily unavailable; retry later."
|
|
27
|
+
if transient
|
|
28
|
+
else f"Session expired or was revoked; run `{login}`."
|
|
29
|
+
)
|
cli_login/locks.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Per-key process locks, on POSIX (``flock``) and on Windows (``msvcrt.locking``).
|
|
2
|
+
|
|
3
|
+
One lock file per stored key, ``session-<key>.lock`` in the private state directory. There is no global lock:
|
|
4
|
+
different keys never block each other.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import errno
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .errors import AuthError
|
|
18
|
+
|
|
19
|
+
_SAFE = re.compile(r"[A-Za-z0-9._-]{1,200}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_state_dir(namespace: str) -> Path:
|
|
23
|
+
return Path.home() / ".config" / "cli-login" / namespace
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_state_dir(path: Path) -> Path:
|
|
27
|
+
"""Create the private (0700) metadata directory, refusing a symlink."""
|
|
28
|
+
path = Path(path)
|
|
29
|
+
if path.is_symlink():
|
|
30
|
+
raise AuthError("CLI session metadata directory must not be a symlink.")
|
|
31
|
+
try:
|
|
32
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
33
|
+
path.chmod(0o700)
|
|
34
|
+
except OSError:
|
|
35
|
+
raise AuthError("Unable to access private CLI session metadata; check directory permissions.") from None
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _lock_posix(fd: int) -> None:
|
|
40
|
+
import fcntl
|
|
41
|
+
|
|
42
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _unlock_posix(fd: int) -> None:
|
|
46
|
+
import fcntl
|
|
47
|
+
|
|
48
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _lock_windows(fd: int) -> None:
|
|
52
|
+
"""Lock byte 0 of the file, waiting without a deadline.
|
|
53
|
+
|
|
54
|
+
``LK_LOCK`` gives up after ten seconds, so poll the non-blocking form with a short, growing sleep.
|
|
55
|
+
"""
|
|
56
|
+
import msvcrt
|
|
57
|
+
|
|
58
|
+
delay = 0.005
|
|
59
|
+
while True:
|
|
60
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
61
|
+
try:
|
|
62
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
63
|
+
return
|
|
64
|
+
except OSError as error:
|
|
65
|
+
if error.errno not in (errno.EACCES, errno.EDEADLK):
|
|
66
|
+
raise
|
|
67
|
+
time.sleep(delay)
|
|
68
|
+
delay = min(delay * 2, 0.1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unlock_windows(fd: int) -> None:
|
|
72
|
+
import msvcrt
|
|
73
|
+
|
|
74
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
75
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if sys.platform == "win32":
|
|
79
|
+
_lock, _unlock = _lock_windows, _unlock_windows
|
|
80
|
+
else:
|
|
81
|
+
_lock, _unlock = _lock_posix, _unlock_posix
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def key_lock(state_dir: Path, key: str):
|
|
86
|
+
"""Hold an exclusive lock for one stored key, never a global lock."""
|
|
87
|
+
if not _SAFE.fullmatch(key or ""):
|
|
88
|
+
raise AuthError("Invalid lock key.")
|
|
89
|
+
path = ensure_state_dir(state_dir) / ("session-" + key + ".lock")
|
|
90
|
+
if path.is_symlink():
|
|
91
|
+
raise AuthError("The CLI session lock must not be a symlink.")
|
|
92
|
+
try:
|
|
93
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
94
|
+
except OSError:
|
|
95
|
+
raise AuthError("Unable to open the private CLI session lock; check directory permissions.") from None
|
|
96
|
+
try:
|
|
97
|
+
try:
|
|
98
|
+
_lock(fd)
|
|
99
|
+
except OSError:
|
|
100
|
+
raise AuthError(
|
|
101
|
+
"Unable to lock the CLI session safely; retry after checking directory permissions."
|
|
102
|
+
) from None
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
finally:
|
|
106
|
+
try:
|
|
107
|
+
_unlock(fd)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass # closing the descriptor releases the lock
|
|
110
|
+
finally:
|
|
111
|
+
os.close(fd)
|
cli_login/oauth.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""PKCE, the authorization URL, the loopback callback receiver and the HTTP provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .errors import AuthError, OAuthError
|
|
18
|
+
from .target import Target, https_url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pkce_pair() -> tuple[str, str]:
|
|
22
|
+
"""A fresh (verifier, S256 challenge) pair."""
|
|
23
|
+
verifier = secrets.token_urlsafe(64)
|
|
24
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
|
25
|
+
return verifier, challenge
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def authorization_url(
|
|
29
|
+
target: Target,
|
|
30
|
+
*,
|
|
31
|
+
state: str,
|
|
32
|
+
challenge: str,
|
|
33
|
+
identity_provider: str | None = None,
|
|
34
|
+
scope: str = "openid email profile",
|
|
35
|
+
) -> str:
|
|
36
|
+
fields = {
|
|
37
|
+
"response_type": "code",
|
|
38
|
+
"client_id": target.client_id,
|
|
39
|
+
"redirect_uri": target.callback_uri,
|
|
40
|
+
"scope": scope,
|
|
41
|
+
"state": state,
|
|
42
|
+
"code_challenge": challenge,
|
|
43
|
+
"code_challenge_method": "S256",
|
|
44
|
+
}
|
|
45
|
+
if identity_provider:
|
|
46
|
+
fields["identity_provider"] = identity_provider
|
|
47
|
+
return target.authorize_endpoint + "?" + urllib.parse.urlencode(fields)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
51
|
+
def redirect_request(self, *args, **kwargs):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HTTPProvider:
|
|
56
|
+
"""Posts form fields to the configured provider. Redirects are refused: secrets go nowhere else."""
|
|
57
|
+
|
|
58
|
+
def post(self, url: str, fields: dict[str, str]) -> dict[str, Any]:
|
|
59
|
+
https_url(url, "OAuth endpoint")
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
url,
|
|
62
|
+
data=urllib.parse.urlencode(fields).encode(),
|
|
63
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
64
|
+
method="POST",
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
opener = urllib.request.build_opener(_NoRedirect())
|
|
68
|
+
with opener.open(request, timeout=20) as response:
|
|
69
|
+
raw = response.read(1024 * 1024)
|
|
70
|
+
return json.loads(raw) if raw else {}
|
|
71
|
+
except urllib.error.HTTPError as error:
|
|
72
|
+
try:
|
|
73
|
+
code = json.loads(error.read(4096)).get("error")
|
|
74
|
+
except (ValueError, AttributeError):
|
|
75
|
+
code = None
|
|
76
|
+
if code == "invalid_grant":
|
|
77
|
+
raise OAuthError("invalid_grant") from None
|
|
78
|
+
if error.code == 429 or error.code >= 500:
|
|
79
|
+
raise OAuthError("temporarily_unavailable", transient=True) from None
|
|
80
|
+
raise AuthError(
|
|
81
|
+
"OAuth request rejected. Check the registered client and callback configuration; no provider payload was logged."
|
|
82
|
+
) from None
|
|
83
|
+
except (OSError, urllib.error.URLError):
|
|
84
|
+
raise OAuthError("temporarily_unavailable", transient=True) from None
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
raise AuthError(
|
|
87
|
+
"Sign-in provider returned an invalid response; retry without discarding your session."
|
|
88
|
+
) from None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def receive_code(callback_uri: str, authorization_url: str, state: str, timeout: float) -> str:
|
|
92
|
+
"""Open the browser and wait for the one authorization code on the loopback callback."""
|
|
93
|
+
callback = urllib.parse.urlsplit(callback_uri)
|
|
94
|
+
outcome: dict[str, str] = {}
|
|
95
|
+
|
|
96
|
+
class Callback(BaseHTTPRequestHandler):
|
|
97
|
+
def log_message(self, *args):
|
|
98
|
+
pass # The request URL contains the authorization code.
|
|
99
|
+
|
|
100
|
+
def do_GET(self):
|
|
101
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
102
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
103
|
+
valid = (
|
|
104
|
+
parsed.path == (callback.path or "/")
|
|
105
|
+
and query.get("state") == [state]
|
|
106
|
+
and ((len(query.get("code", [])) == 1 and bool(query["code"][0])) or len(query.get("error", [])) == 1)
|
|
107
|
+
)
|
|
108
|
+
self.send_response(200 if valid else 400)
|
|
109
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
110
|
+
self.send_header("Cache-Control", "no-store")
|
|
111
|
+
self.end_headers()
|
|
112
|
+
self.wfile.write(
|
|
113
|
+
b"Sign-in response received. You may close this page." if valid else b"Invalid sign-in response."
|
|
114
|
+
)
|
|
115
|
+
if valid:
|
|
116
|
+
outcome.update(code=query.get("code", [""])[0], error="denied" if query.get("error") else "")
|
|
117
|
+
|
|
118
|
+
class CallbackServer(HTTPServer):
|
|
119
|
+
def get_request(self):
|
|
120
|
+
connection, address = super().get_request()
|
|
121
|
+
connection.settimeout(min(5, timeout))
|
|
122
|
+
return connection, address
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
# Never resolve the registered host to pick an interface, and never bind a wildcard.
|
|
126
|
+
server = CallbackServer(("127.0.0.1", callback.port), Callback)
|
|
127
|
+
except OSError:
|
|
128
|
+
raise AuthError(
|
|
129
|
+
f"Sign-in needs local port {callback.port} for its callback, but it is already in use. "
|
|
130
|
+
"Stop whatever is using it and run the login again."
|
|
131
|
+
) from None
|
|
132
|
+
with server:
|
|
133
|
+
server.timeout = min(0.25, timeout)
|
|
134
|
+
if not webbrowser.open(authorization_url):
|
|
135
|
+
raise AuthError("Could not open the system browser. Run the login in an interactive desktop session.")
|
|
136
|
+
deadline = time.monotonic() + timeout
|
|
137
|
+
while not outcome and time.monotonic() < deadline:
|
|
138
|
+
server.handle_request()
|
|
139
|
+
if not outcome:
|
|
140
|
+
raise AuthError("Sign-in timed out. Run the login again.")
|
|
141
|
+
if outcome.get("error") or not outcome.get("code"):
|
|
142
|
+
raise AuthError("Sign-in was declined. No session was saved.")
|
|
143
|
+
return outcome["code"]
|