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.
- {elva_cli-0.2.0 → elva_cli-0.3.0}/PKG-INFO +2 -1
- {elva_cli-0.2.0 → elva_cli-0.3.0}/pyproject.toml +1 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/_version.py +2 -2
- elva_cli-0.3.0/src/elva_cli/auth/__init__.py +32 -0
- elva_cli-0.3.0/src/elva_cli/auth/models.py +100 -0
- elva_cli-0.3.0/src/elva_cli/auth/session.py +343 -0
- elva_cli-0.3.0/src/elva_cli/auth/store.py +121 -0
- elva_cli-0.3.0/src/elva_cli/commands/auth.py +37 -0
- elva_cli-0.3.0/src/elva_cli/core/services/auth.py +197 -0
- elva_cli-0.3.0/src/elva_cli/core/services/auth_result.py +40 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/registry.py +1 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/__init__.py +1 -0
- elva_cli-0.3.0/src/elva_cli/ui/renderables/auth.py +25 -0
- elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +52 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_never_blocks.py +14 -3
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_context.py +47 -0
- elva_cli-0.3.0/tests/unit/test_credentials.py +83 -0
- elva_cli-0.3.0/tests/unit/test_login_flow.py +318 -0
- elva_cli-0.3.0/tests/unit/test_logout_flow.py +35 -0
- elva_cli-0.3.0/tests/unit/test_session.py +764 -0
- elva_cli-0.3.0/tests/unit/test_token_store.py +199 -0
- elva_cli-0.2.0/src/elva_cli/auth/__init__.py +0 -8
- {elva_cli-0.2.0 → elva_cli-0.3.0}/.gitignore +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/README.md +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/commands/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/context.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/services/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/main.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/console.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/output.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/base.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/renderables/config.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/theme.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_config_command.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/cli/test_output_streams.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_boundary.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_interactivity.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_output.py +0 -0
- {elva_cli-0.2.0 → elva_cli-0.3.0}/tests/unit/test_prompts.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (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
|
+
)
|