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.
@@ -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"]