elva-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (63) hide show
  1. {elva_cli-0.2.0 → elva_cli-0.3.0}/PKG-INFO +2 -1
  2. {elva_cli-0.2.0 → elva_cli-0.3.0}/pyproject.toml +1 -0
  3. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/_version.py +2 -2
  4. elva_cli-0.3.0/src/elva_cli/auth/__init__.py +32 -0
  5. elva_cli-0.3.0/src/elva_cli/auth/models.py +100 -0
  6. elva_cli-0.3.0/src/elva_cli/auth/session.py +343 -0
  7. elva_cli-0.3.0/src/elva_cli/auth/store.py +121 -0
  8. elva_cli-0.3.0/src/elva_cli/commands/auth.py +37 -0
  9. elva_cli-0.3.0/src/elva_cli/core/services/auth.py +197 -0
  10. elva_cli-0.3.0/src/elva_cli/core/services/auth_result.py +40 -0
  11. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/registry.py +1 -0
  12. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/__init__.py +1 -0
  13. elva_cli-0.3.0/src/elva_cli/ui/renderables/auth.py +25 -0
  14. elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +52 -0
  15. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_never_blocks.py +14 -3
  16. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_context.py +47 -0
  17. elva_cli-0.3.0/tests/unit/test_credentials.py +83 -0
  18. elva_cli-0.3.0/tests/unit/test_login_flow.py +318 -0
  19. elva_cli-0.3.0/tests/unit/test_logout_flow.py +35 -0
  20. elva_cli-0.3.0/tests/unit/test_session.py +764 -0
  21. elva_cli-0.3.0/tests/unit/test_token_store.py +199 -0
  22. elva_cli-0.2.0/src/elva_cli/auth/__init__.py +0 -8
  23. {elva_cli-0.2.0 → elva_cli-0.3.0}/.gitignore +0 -0
  24. {elva_cli-0.2.0 → elva_cli-0.3.0}/README.md +0 -0
  25. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/__init__.py +0 -0
  26. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/__main__.py +0 -0
  27. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/commands/__init__.py +0 -0
  28. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/commands/config.py +0 -0
  29. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/context.py +0 -0
  30. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/__init__.py +0 -0
  31. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/api/__init__.py +0 -0
  32. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/services/__init__.py +0 -0
  33. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/services/config.py +0 -0
  34. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/spec/__init__.py +0 -0
  35. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/errors.py +0 -0
  36. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/logging.py +0 -0
  37. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/main.py +0 -0
  38. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/__init__.py +0 -0
  39. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/loader.py +0 -0
  40. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/models.py +0 -0
  41. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/paths.py +0 -0
  42. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/telemetry.py +0 -0
  43. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/__init__.py +0 -0
  44. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/console.py +0 -0
  45. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/output.py +0 -0
  46. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/prompts.py +0 -0
  47. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/base.py +0 -0
  48. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/config.py +0 -0
  49. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/theme.py +0 -0
  50. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/views/__init__.py +0 -0
  51. {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/update.py +0 -0
  52. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_cli_exit_codes.py +0 -0
  53. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_config_command.py +0 -0
  54. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_lazy_imports.py +0 -0
  55. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_output_streams.py +0 -0
  56. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_boundary.py +0 -0
  57. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_error_boundary.py +0 -0
  58. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_errors.py +0 -0
  59. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_exit_codes.py +0 -0
  60. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_interactivity.py +0 -0
  61. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_output.py +0 -0
  62. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_prompts.py +0 -0
  63. {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_settings_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: elva-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Elva - CLI for Theneo Elva
5
5
  Project-URL: Homepage, https://getelva.ai
6
6
  Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Topic :: Software Development :: Documentation
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
+ Requires-Dist: keyring>=25
19
20
  Requires-Dist: platformdirs>=4.2
20
21
  Requires-Dist: pydantic>=2.7
21
22
  Requires-Dist: typer<1.0,>=0.15
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "typer>=0.15,<1.0",
26
26
  "platformdirs>=4.2",
27
27
  "pydantic>=2.7",
28
+ "keyring>=25",
28
29
  ]
29
30
 
30
31
  [project.optional-dependencies]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,32 @@
1
+ """Credentials and the login flow.
2
+
3
+ Token storage sits behind a Protocol (keyring, with a 0600 file fallback for
4
+ headless Linux and containers). ELVA_TOKEN overrides both in CI. A session's
5
+ access token refreshes itself transparently on a near-expiry read; a dead
6
+ session raises AuthError (exit code 3).
7
+
8
+ The browser handoff needs a /auth/cli endpoint on the JWT side of the
9
+ backend. The cookie-session auth used by the catalog and GitHub routes is
10
+ deliberately out of scope: a CLI has no cookie jar.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from elva_cli.auth.models import Credentials
18
+
19
+ if TYPE_CHECKING:
20
+ from elva_cli.auth.session import ENV_TOKEN, get_access_token, logout, save_login, save_pat
21
+
22
+ __all__ = ["ENV_TOKEN", "Credentials", "get_access_token", "logout", "save_login", "save_pat"]
23
+
24
+ _LAZY = frozenset(__all__) - {"Credentials"}
25
+
26
+
27
+ def __getattr__(name: str) -> object:
28
+ if name in _LAZY:
29
+ from elva_cli.auth import session
30
+
31
+ return getattr(session, name)
32
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,100 @@
1
+ """The Credentials shape: what gets stored, and how it maps to the wire."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any, Literal
8
+
9
+ TokenKind = Literal["session", "pat"]
10
+
11
+
12
+ def _parse_expiry(value: str) -> datetime:
13
+ """Parse an ISO-8601 timestamp, forcing it timezone-aware.
14
+
15
+ The backend sends UTC with a `Z`, but a hand-edited credentials.json can
16
+ drop the offset. A naive datetime would later crash get_access_token's
17
+ `expires - datetime.now(UTC)` arithmetic, so assume UTC when none is
18
+ given."""
19
+ parsed = datetime.fromisoformat(value)
20
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Credentials:
25
+ """One stored credential: either an OAuth session (JWT pair, refreshable)
26
+ or a Personal Access Token (opaque, long-lived, no refresh side)."""
27
+
28
+ kind: TokenKind
29
+ access_token: str
30
+ access_expires_at: datetime | None
31
+ refresh_token: str | None
32
+ refresh_expires_at: datetime | None
33
+
34
+ @classmethod
35
+ def from_auth_tokens(cls, tokens: dict[str, Any]) -> Credentials:
36
+ """Build a session credential from the backend's AuthTokens shape:
37
+ {"access": {"token", "expires"}, "refresh": {"token", "expires"}}.
38
+
39
+ POST /api/auth/refresh-tokens returns this shape at the top level.
40
+ The CLI login exchange (POST /api/auth/cli/token) nests it under a
41
+ "tokens" key alongside "user" — callers there go through
42
+ `session.save_login`, which unwraps it first."""
43
+ access = tokens["access"]
44
+ refresh = tokens["refresh"]
45
+ return cls(
46
+ kind="session",
47
+ access_token=access["token"],
48
+ access_expires_at=_parse_expiry(access["expires"]),
49
+ refresh_token=refresh["token"],
50
+ refresh_expires_at=_parse_expiry(refresh["expires"]),
51
+ )
52
+
53
+ @classmethod
54
+ def from_pat(cls, token: str) -> Credentials:
55
+ return cls(
56
+ kind="pat",
57
+ access_token=token,
58
+ access_expires_at=None,
59
+ refresh_token=None,
60
+ refresh_expires_at=None,
61
+ )
62
+
63
+ def to_json(self) -> dict[str, Any]:
64
+ return {
65
+ "kind": self.kind,
66
+ "access_token": self.access_token,
67
+ "access_expires_at": (
68
+ self.access_expires_at.isoformat() if self.access_expires_at else None
69
+ ),
70
+ "refresh_token": self.refresh_token,
71
+ "refresh_expires_at": (
72
+ self.refresh_expires_at.isoformat() if self.refresh_expires_at else None
73
+ ),
74
+ }
75
+
76
+ @classmethod
77
+ def from_json(cls, data: dict[str, Any]) -> Credentials:
78
+ kind = data["kind"]
79
+ if kind not in ("session", "pat"):
80
+ raise ValueError(f"unknown credential kind: {kind!r}")
81
+ creds = cls(
82
+ kind=kind,
83
+ access_token=data["access_token"],
84
+ access_expires_at=(
85
+ _parse_expiry(data["access_expires_at"]) if data.get("access_expires_at") else None
86
+ ),
87
+ refresh_token=data.get("refresh_token"),
88
+ refresh_expires_at=(
89
+ _parse_expiry(data["refresh_expires_at"])
90
+ if data.get("refresh_expires_at")
91
+ else None
92
+ ),
93
+ )
94
+ if creds.kind == "session" and (
95
+ creds.access_expires_at is None
96
+ or creds.refresh_token is None
97
+ or creds.refresh_expires_at is None
98
+ ):
99
+ raise ValueError("incomplete session credential")
100
+ return creds
@@ -0,0 +1,343 @@
1
+ """Precedence between credential sources, and transparent session refresh.
2
+
3
+ Order: ELVA_TOKEN env var, then whichever TokenStore (keyring, then file)
4
+ has something. A session's access token refreshes itself silently on a
5
+ near-expiry read; a PAT is used as-is (no expiry the client knows about).
6
+ A refresh token that's also expired, or a refresh the backend positively
7
+ rejects (401/403), surfaces as AuthError — exit code 3, "Run 'elva auth
8
+ login' to sign in." A transient refresh failure (network down, timeout,
9
+ 5xx) surfaces as ApiError and leaves stored credentials intact.
10
+
11
+ The refresh itself is serialised across concurrent `elva` processes with a
12
+ file lock (see _refresh_lock): the backend spends a refresh token on first
13
+ use, so two processes racing to refresh the same near-expiry session would
14
+ have one 401 and, on that 401, clear the credentials the other just saved.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import json
21
+ import logging
22
+ import os
23
+ from datetime import UTC, datetime, timedelta
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from elva_cli.auth.models import Credentials
27
+ from elva_cli.auth.store import FileStore, KeyringStore, StoreUnavailableError, TokenStore
28
+ from elva_cli.core.services.auth_result import LogoutResult as LogoutResult
29
+ from elva_cli.core.services.auth_result import LogoutStatus as LogoutStatus
30
+ from elva_cli.errors import ApiError, AuthError
31
+ from elva_cli.settings import paths
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Iterator
35
+
36
+ _logger = logging.getLogger(__name__)
37
+
38
+ ENV_TOKEN = "ELVA_TOKEN"
39
+ _SKEW = timedelta(seconds=60)
40
+ _LOCK_FILE = "refresh.lock"
41
+ _HTTP_TIMEOUT = 10
42
+ _LOGOUT_TIMEOUT = 5
43
+
44
+
45
+ class RefreshFailedError(Exception):
46
+ """The backend positively rejected this refresh token (401/403) — the
47
+ session is dead and its credentials should be cleared.
48
+
49
+ A transient failure (network down, timeout, 5xx, a captive-portal error
50
+ page) is NOT this — it raises ApiError instead and leaves credentials
51
+ untouched, so a flaky connection can't force a full re-login."""
52
+
53
+
54
+ def _stores() -> list[TokenStore]:
55
+ return [KeyringStore(), FileStore()]
56
+
57
+
58
+ def _load_from_first_available_store() -> tuple[Credentials | None, TokenStore | None]:
59
+ for store in _stores():
60
+ creds = store.load()
61
+ if creds is not None:
62
+ return creds, store
63
+ return None, None
64
+
65
+
66
+ def _clear_all_stores() -> None:
67
+ for store in _stores():
68
+ store.clear()
69
+
70
+
71
+ def _save_preferring_keyring(creds: Credentials) -> None:
72
+ """Persist to exactly one store and clear the other, so a stale copy in
73
+ the store we're *not* using can never shadow this one on the next read
74
+ (get_access_token checks the keyring first)."""
75
+ try:
76
+ KeyringStore().save(creds)
77
+ except StoreUnavailableError:
78
+ FileStore().save(creds)
79
+ KeyringStore().clear()
80
+ else:
81
+ FileStore().clear()
82
+
83
+
84
+ def _persist_refreshed(creds: Credentials) -> None:
85
+ """Save a freshly refreshed session.
86
+
87
+ The refresh token that produced it is already spent server-side, so if
88
+ this doesn't land the next run is a forced re-login. Go through
89
+ _save_preferring_keyring so a single failing store falls back to the
90
+ other one, and if even that fails, say so instead of failing silently.
91
+ This module sits below the ui/ boundary (see tests/unit/test_boundary.py)
92
+ and must not print — logging is the escape hatch for a below-boundary
93
+ module that still needs to surface something; elva_cli.logging is what
94
+ wires a handler up to it."""
95
+ try:
96
+ _save_preferring_keyring(creds)
97
+ except (StoreUnavailableError, OSError):
98
+ _logger.warning(
99
+ "your session was refreshed but could not be saved; "
100
+ "you may need to run 'elva auth login' again."
101
+ )
102
+
103
+
104
+ def _acquire_lock_fd() -> int | None:
105
+ """An fd holding an exclusive advisory lock on the refresh lock file, or
106
+ None if locking isn't available here (non-POSIX, or a filesystem with no
107
+ working flock — NFS without lockd, some container overlays). A None means
108
+ "couldn't serialise"; the caller then just refreshes unlocked, exactly as
109
+ it did before this lock existed."""
110
+ try:
111
+ import fcntl
112
+ except ImportError: # non-POSIX
113
+ return None
114
+
115
+ try:
116
+ directory = paths.config_dir()
117
+ directory.mkdir(parents=True, exist_ok=True)
118
+ fd = os.open(directory / _LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600)
119
+ except OSError:
120
+ return None
121
+
122
+ try:
123
+ fcntl.flock(fd, fcntl.LOCK_EX)
124
+ except OSError:
125
+ with contextlib.suppress(OSError):
126
+ os.close(fd)
127
+ return None
128
+ return fd
129
+
130
+
131
+ @contextlib.contextmanager
132
+ def _refresh_lock() -> Iterator[None]:
133
+ """Hold the cross-process refresh lock for the duration of the block.
134
+
135
+ Best-effort: if the lock can't be taken the block still runs, just
136
+ without serialisation. The caller must re-read the store inside the
137
+ block — by the time a waiter gets in, the process that held the lock has
138
+ usually already saved a fresh token."""
139
+ fd = _acquire_lock_fd()
140
+ if fd is None:
141
+ yield
142
+ return
143
+ try:
144
+ yield
145
+ finally:
146
+ import fcntl
147
+
148
+ with contextlib.suppress(OSError):
149
+ fcntl.flock(fd, fcntl.LOCK_UN)
150
+ with contextlib.suppress(OSError):
151
+ os.close(fd)
152
+
153
+
154
+ def _refresh(refresh_token: str, *, base_url: str, timeout: float = _HTTP_TIMEOUT) -> Credentials:
155
+ import urllib.error
156
+ import urllib.request
157
+
158
+ request = urllib.request.Request(
159
+ f"{base_url}/api/auth/refresh-tokens",
160
+ data=json.dumps({"refreshToken": refresh_token}).encode("utf-8"),
161
+ headers={"Content-Type": "application/json"},
162
+ method="POST",
163
+ )
164
+ try:
165
+ with urllib.request.urlopen(request, timeout=timeout) as response:
166
+ raw = response.read()
167
+ except urllib.error.HTTPError as exc:
168
+ if exc.code in (401, 403):
169
+ raise RefreshFailedError(f"backend rejected refresh token (HTTP {exc.code})") from exc
170
+ raise ApiError(f"Refreshing your session failed (HTTP {exc.code}).") from exc
171
+ except (urllib.error.URLError, TimeoutError) as exc:
172
+ raise ApiError("Could not reach the server to refresh your session.") from exc
173
+
174
+ unexpected = "The server returned an unexpected response while refreshing your session."
175
+ try:
176
+ body = json.loads(raw)
177
+ except json.JSONDecodeError as exc:
178
+ raise ApiError(unexpected) from exc
179
+ if not isinstance(body, dict):
180
+ raise ApiError(unexpected)
181
+ try:
182
+ return Credentials.from_auth_tokens(body)
183
+ except (KeyError, ValueError, TypeError) as exc:
184
+ raise ApiError(unexpected) from exc
185
+
186
+
187
+ def get_access_token(*, base_url: str) -> str:
188
+ """The bearer token to send on this request. Refreshes a near-expiry
189
+ session transparently; raises AuthError if there's nothing usable."""
190
+ env_token = os.environ.get(ENV_TOKEN)
191
+ if env_token:
192
+ return env_token
193
+
194
+ creds, store = _load_from_first_available_store()
195
+ if creds is None or store is None:
196
+ raise AuthError("You're not logged in.")
197
+
198
+ if creds.kind == "pat":
199
+ return creds.access_token
200
+
201
+ now = datetime.now(UTC)
202
+ if (
203
+ creds.access_expires_at is None
204
+ or creds.refresh_expires_at is None
205
+ or creds.refresh_token is None
206
+ ):
207
+ raise AuthError("You're not logged in.")
208
+
209
+ if creds.access_expires_at - now > _SKEW:
210
+ return creds.access_token
211
+
212
+ if creds.refresh_expires_at <= now:
213
+ _clear_all_stores()
214
+ raise AuthError("Your session has expired.")
215
+
216
+ return _refresh_session(base_url=base_url)
217
+
218
+
219
+ def _refresh_session(*, base_url: str) -> str:
220
+ """Refresh the stored session under the cross-process lock and return the
221
+ new access token.
222
+
223
+ The store is re-read *inside* the lock: a process that waited for the
224
+ lock normally finds that the holder already refreshed and saved, and
225
+ returns that token without a network call. This is what stops a
226
+ single-use refresh token from being spent twice — the second spend 401s,
227
+ and the 401 handler would wipe the other process's fresh credentials."""
228
+ with _refresh_lock():
229
+ creds, store = _load_from_first_available_store()
230
+ if (
231
+ creds is None
232
+ or store is None
233
+ or creds.kind != "session"
234
+ or creds.access_expires_at is None
235
+ or creds.refresh_expires_at is None
236
+ or creds.refresh_token is None
237
+ ):
238
+ raise AuthError("Your session has expired.")
239
+
240
+ now = datetime.now(UTC)
241
+ if creds.access_expires_at - now > _SKEW:
242
+ return creds.access_token
243
+
244
+ if creds.refresh_expires_at <= now:
245
+ _clear_all_stores()
246
+ raise AuthError("Your session has expired.")
247
+
248
+ try:
249
+ refreshed = _refresh(creds.refresh_token, base_url=base_url)
250
+ except RefreshFailedError as exc:
251
+ _clear_all_stores()
252
+ raise AuthError("Your session has expired.") from exc
253
+ except ApiError:
254
+ if creds.access_expires_at - datetime.now(UTC) > timedelta(0):
255
+ return creds.access_token
256
+ raise
257
+
258
+ _persist_refreshed(refreshed)
259
+ return refreshed.access_token
260
+
261
+
262
+ def save_login(payload: dict[str, Any]) -> None:
263
+ """Persist a fresh OAuth session from the CLI login exchange.
264
+
265
+ POST /api/auth/cli/token responds with {"user": ..., "tokens": {...}};
266
+ the refresh endpoint responds with the bare {"access", "refresh"} shape.
267
+ Accept either so callers don't have to care which one they hold."""
268
+ tokens = payload.get("tokens", payload)
269
+ _save_preferring_keyring(Credentials.from_auth_tokens(tokens))
270
+
271
+
272
+ def save_pat(token: str) -> None:
273
+ """Persist a Personal Access Token."""
274
+ _save_preferring_keyring(Credentials.from_pat(token))
275
+
276
+
277
+ def _revoke_server_side(
278
+ access_token: str, *, base_url: str, timeout: float = _HTTP_TIMEOUT
279
+ ) -> bool:
280
+ """POST /api/auth/logout."""
281
+ import urllib.error
282
+ import urllib.request
283
+
284
+ request = urllib.request.Request(
285
+ f"{base_url}/api/auth/logout",
286
+ data=b"",
287
+ headers={"Authorization": f"Bearer {access_token}"},
288
+ method="POST",
289
+ )
290
+ try:
291
+ with urllib.request.urlopen(request, timeout=timeout):
292
+ pass
293
+ except (urllib.error.URLError, TimeoutError):
294
+ return False
295
+ return True
296
+
297
+
298
+ def _revocation_token(
299
+ creds: Credentials, *, base_url: str, timeout: float = _HTTP_TIMEOUT
300
+ ) -> str | None:
301
+ """An access token that will still authenticate the logout call, or None
302
+ if the session is already dead server-side and there's nothing to revoke."""
303
+ now = datetime.now(UTC)
304
+ if creds.access_expires_at is not None and creds.access_expires_at - now > _SKEW:
305
+ return creds.access_token
306
+ if (
307
+ creds.refresh_token is None
308
+ or creds.refresh_expires_at is None
309
+ or creds.refresh_expires_at <= now
310
+ ):
311
+ return None
312
+ try:
313
+ refreshed = _refresh(creds.refresh_token, base_url=base_url, timeout=timeout)
314
+ except RefreshFailedError:
315
+ return None
316
+ _persist_refreshed(refreshed)
317
+ return refreshed.access_token
318
+
319
+
320
+ def logout(*, base_url: str) -> LogoutResult:
321
+ """Forget the stored credentials and, best-effort, revoke the session
322
+ server-side (POST /api/auth/logout)."""
323
+ with _refresh_lock():
324
+ creds, _ = _load_from_first_available_store()
325
+ if creds is None:
326
+ _clear_all_stores()
327
+ return LogoutResult(status=LogoutStatus.NOT_SIGNED_IN)
328
+ if creds.kind == "pat":
329
+ _clear_all_stores()
330
+ return LogoutResult(status=LogoutStatus.SIGNED_OUT)
331
+ try:
332
+ token = _revocation_token(creds, base_url=base_url, timeout=_LOGOUT_TIMEOUT)
333
+ except ApiError:
334
+ return LogoutResult(status=LogoutStatus.REVOCATION_FAILED)
335
+ if token is None:
336
+ # Refresh token expired or rejected — the session is already dead
337
+ _clear_all_stores()
338
+ return LogoutResult(status=LogoutStatus.SIGNED_OUT)
339
+ if _revoke_server_side(token, base_url=base_url, timeout=_LOGOUT_TIMEOUT):
340
+ _clear_all_stores()
341
+ return LogoutResult(status=LogoutStatus.SIGNED_OUT)
342
+ # The token was good but the POST didn't land
343
+ return LogoutResult(status=LogoutStatus.REVOCATION_FAILED)
@@ -0,0 +1,121 @@
1
+ """Where a Credentials blob actually lives: OS keyring first, a 0600 file
2
+ for machines with no keyring backend (headless Linux, containers).
3
+
4
+ Deliberately does NOT depend on `keyrings.alt`: without it, `keyring` fails
5
+ over cleanly on a box with no real secret service instead of silently
6
+ writing through its own weakly-obfuscated plaintext backend — so it's
7
+ *this* module's documented file fallback that fires, not a hidden one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import os
15
+ import tempfile
16
+ from pathlib import Path
17
+ from typing import Protocol
18
+
19
+ from elva_cli.auth.models import Credentials
20
+ from elva_cli.settings import paths
21
+
22
+ _SERVICE = "elva-cli"
23
+ _USERNAME = "default"
24
+ _CREDENTIALS_FILE = "credentials.json"
25
+
26
+
27
+ class StoreUnavailableError(Exception):
28
+ """This store can't be written to right now (e.g. no keyring backend).
29
+
30
+ Internal to auth/ — callers fall through to the next store, never a
31
+ user-facing error on its own.
32
+ """
33
+
34
+
35
+ class TokenStore(Protocol):
36
+ def load(self) -> Credentials | None: ...
37
+ def save(self, creds: Credentials) -> None: ...
38
+ def clear(self) -> None: ...
39
+
40
+
41
+ class KeyringStore:
42
+ """Backed by the OS keyring (Keychain / libsecret / Credential Manager)."""
43
+
44
+ def load(self) -> Credentials | None:
45
+ import keyring
46
+
47
+ try:
48
+ raw = keyring.get_password(_SERVICE, _USERNAME)
49
+ except Exception:
50
+ return None
51
+ if raw is None:
52
+ return None
53
+ try:
54
+ return Credentials.from_json(json.loads(raw))
55
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
56
+ return None
57
+
58
+ def save(self, creds: Credentials) -> None:
59
+ import keyring
60
+
61
+ try:
62
+ keyring.set_password(_SERVICE, _USERNAME, json.dumps(creds.to_json()))
63
+ except Exception as exc:
64
+ raise StoreUnavailableError(str(exc)) from exc
65
+
66
+ def clear(self) -> None:
67
+ import keyring
68
+
69
+ # Already absent, or no backend — either way, nothing to clear.
70
+ with contextlib.suppress(Exception):
71
+ keyring.delete_password(_SERVICE, _USERNAME)
72
+
73
+
74
+ class FileStore:
75
+ """0600 JSON file under the CLI's config dir. The documented fallback
76
+ for headless Linux / containers where no keyring daemon is running."""
77
+
78
+ def _path(self) -> Path:
79
+ return paths.config_dir() / _CREDENTIALS_FILE
80
+
81
+ def load(self) -> Credentials | None:
82
+ path = self._path()
83
+ try:
84
+ raw = path.read_text(encoding="utf-8")
85
+ except OSError:
86
+ return None
87
+ try:
88
+ return Credentials.from_json(json.loads(raw))
89
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
90
+ return None
91
+
92
+ def save(self, creds: Credentials) -> None:
93
+ directory = paths.config_dir()
94
+ directory.mkdir(parents=True, exist_ok=True)
95
+ directory.chmod(0o700)
96
+
97
+ path = directory / _CREDENTIALS_FILE
98
+ payload = json.dumps(creds.to_json()).encode("utf-8")
99
+
100
+ fd, tmp_name = tempfile.mkstemp(
101
+ prefix=f"{_CREDENTIALS_FILE}.", suffix=".tmp", dir=directory
102
+ )
103
+ tmp_path = Path(tmp_name)
104
+ try:
105
+ try:
106
+ os.write(fd, payload)
107
+ finally:
108
+ os.close(fd)
109
+ tmp_path.replace(path) # atomic on POSIX and Windows
110
+ except BaseException:
111
+ with contextlib.suppress(OSError):
112
+ tmp_path.unlink()
113
+ raise
114
+
115
+ def clear(self) -> None:
116
+ with contextlib.suppress(OSError):
117
+ self._path().unlink(missing_ok=True)
118
+ # Also sweep any temp files left by an interrupted save.
119
+ for leftover in self._path().parent.glob(f"{_CREDENTIALS_FILE}.*.tmp"):
120
+ with contextlib.suppress(OSError):
121
+ leftover.unlink()
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from elva_cli.context import get_ctx
6
+
7
+ app = typer.Typer(name="auth", help="Sign in and manage credentials.", no_args_is_help=True)
8
+
9
+
10
+ @app.command("login")
11
+ def login(click_ctx: typer.Context) -> None:
12
+ """Sign in via your browser."""
13
+ from elva_cli.core.services.auth import login as login_service
14
+
15
+ ctx = get_ctx(click_ctx)
16
+ result = login_service(
17
+ base_url=ctx.settings.base_url,
18
+ on_progress=ctx.out.hint,
19
+ interactive=ctx.interactive,
20
+ )
21
+ ctx.out.result(result)
22
+
23
+
24
+ @app.command("logout")
25
+ def logout(click_ctx: typer.Context) -> None:
26
+ """Sign out and forget your stored credentials."""
27
+ from elva_cli.auth import ENV_TOKEN
28
+ from elva_cli.core.services.auth import logout as logout_service
29
+
30
+ ctx = get_ctx(click_ctx)
31
+ result = logout_service(base_url=ctx.settings.base_url)
32
+ ctx.out.result(result)
33
+ if ctx.env.get(ENV_TOKEN):
34
+ ctx.out.warn(
35
+ f"{ENV_TOKEN} is set and will still be used to authenticate. "
36
+ "Unset it to fully sign out."
37
+ )