elan-cli 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.
- elan_cli/__init__.py +6 -0
- elan_cli/adk.py +197 -0
- elan_cli/auth.py +391 -0
- elan_cli/client.py +115 -0
- elan_cli/config.py +139 -0
- elan_cli/deploy.py +247 -0
- elan_cli/destroy.py +77 -0
- elan_cli/doctor.py +63 -0
- elan_cli/env.py +96 -0
- elan_cli/files.py +166 -0
- elan_cli/invoke.py +196 -0
- elan_cli/logs.py +149 -0
- elan_cli/main.py +664 -0
- elan_cli/manifests.py +124 -0
- elan_cli/project.py +137 -0
- elan_cli/projects.py +72 -0
- elan_cli/rollback.py +68 -0
- elan_cli/scaffold.py +408 -0
- elan_cli/secret.py +105 -0
- elan_cli/stream.py +162 -0
- elan_cli/tenant.py +59 -0
- elan_cli/validate.py +91 -0
- elan_cli/wallet.py +213 -0
- elan_cli-0.1.0.dist-info/METADATA +96 -0
- elan_cli-0.1.0.dist-info/RECORD +28 -0
- elan_cli-0.1.0.dist-info/WHEEL +4 -0
- elan_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elan_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
elan_cli/__init__.py
ADDED
elan_cli/adk.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""`elan adk` — ADK's own tools, pointed at this repo.
|
|
2
|
+
|
|
3
|
+
WHY A WRAPPER AND NOT JUST `adk web`. ADK's loader looks in
|
|
4
|
+
`<agents_dir>/<name>/agent.py` for a module-level `app` or `root_agent`. A
|
|
5
|
+
target here exports `MANIFEST` and `build(ctx)` and neither of those names,
|
|
6
|
+
because the platform supplies the other half: `build_app` is what turns a
|
|
7
|
+
`build()` into an ADK `App`, and in a pod the engine does it. So `adk web`
|
|
8
|
+
against a tenant repo finds nothing and says so as "no agents", which reads as
|
|
9
|
+
the repo being empty.
|
|
10
|
+
|
|
11
|
+
THE SHIMS ARE GENERATED, NOT SCAFFOLDED INTO THE REPO. The obvious fix is to
|
|
12
|
+
append eight lines to every `agent.py`, and it works — but those lines run AT
|
|
13
|
+
IMPORT, and `elan validate` and the build's manifest export both import target
|
|
14
|
+
modules to read their MANIFEST. That makes every deploy build a whole App to
|
|
15
|
+
answer a question about a dataclass, and the day a `build()` needs something
|
|
16
|
+
only a laptop has, the BUILD breaks at manifest export naming nothing useful.
|
|
17
|
+
Generated modules keep the target files exporting exactly what the contract
|
|
18
|
+
says.
|
|
19
|
+
|
|
20
|
+
WHAT YOU GET IS THE REAL STACK, which is the point. The shim calls the same
|
|
21
|
+
`build_plugins` a pod does, so `adk web` runs the agent behind the autonomy
|
|
22
|
+
gate, the typing plugin and the observability plugin. The ledger is absent
|
|
23
|
+
because `LEDGER_SERVICE_URL` is unset, and it says so rather than pretending:
|
|
24
|
+
"running unbilled (local dev only)".
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from elan_cli.manifests import ManifestError, interpreter
|
|
35
|
+
from elan_cli.manifests import read as read_manifests
|
|
36
|
+
from elan_cli.project import Project, ProjectError, find
|
|
37
|
+
|
|
38
|
+
#: Under a dot directory so it never looks like part of the repo, and never
|
|
39
|
+
#: collides with `agents/` — which ADK would otherwise ALSO scan, finding the
|
|
40
|
+
#: real modules, failing to load them, and reporting both.
|
|
41
|
+
SHIM_ROOT = Path(".elan") / "adk"
|
|
42
|
+
|
|
43
|
+
#: Commands that take a directory of agents as their last positional.
|
|
44
|
+
DIRECTORY_COMMANDS = {"web", "api_server"}
|
|
45
|
+
|
|
46
|
+
#: Commands that take ONE agent, named by slug.
|
|
47
|
+
TARGET_COMMANDS = {"run"}
|
|
48
|
+
|
|
49
|
+
SHIM = '''\
|
|
50
|
+
"""GENERATED by `elan adk` for {slug}. Rewritten on every run; do not edit.
|
|
51
|
+
|
|
52
|
+
The whole file exists to give ADK's loader the name it looks for. Everything
|
|
53
|
+
it does is what the runtime does at pod start, so what you drive here is the
|
|
54
|
+
agent as deployed rather than a stripped-down copy of it.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from agentkit_runtime.builder import build_app
|
|
60
|
+
from agentkit_runtime.config import Settings
|
|
61
|
+
from agentkit_runtime.plugins import build_plugins
|
|
62
|
+
from agentkit_runtime.registry import resolve
|
|
63
|
+
|
|
64
|
+
# PASSED, NOT INHERITED. `Settings` would otherwise read TARGETS_ROOT from the
|
|
65
|
+
# environment, and `adk` is started from wherever the developer happens to be.
|
|
66
|
+
_settings = Settings(targets_root=Path(r"{root}"))
|
|
67
|
+
_target = resolve({slug!r}, _settings)
|
|
68
|
+
_plugins, _ = build_plugins(_target, _settings,
|
|
69
|
+
_target.version_stamp(_settings.build_sha))
|
|
70
|
+
|
|
71
|
+
# `app`, WHICH ADK CHECKS FOR BEFORE `root_agent`. The App carries the plugin
|
|
72
|
+
# stack; a bare root_agent would run the agent with none of it.
|
|
73
|
+
app = build_app(_target, _settings, plugins=_plugins)
|
|
74
|
+
'''
|
|
75
|
+
|
|
76
|
+
#: A directory that ignores itself, so this works in a repo whose .gitignore
|
|
77
|
+
#: predates `elan init` — which is every repo scaffolded before this existed.
|
|
78
|
+
SELF_IGNORE = "*\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def module_name(slug: str) -> str:
|
|
82
|
+
"""The directory ADK will import this target as.
|
|
83
|
+
|
|
84
|
+
SLUGS CONTAIN HYPHENS AND MODULE NAMES CANNOT. ADK imports the DIRECTORY
|
|
85
|
+
NAME — `importlib.import_module(f"{name}.agent")` — so a shim directory
|
|
86
|
+
called `fish-facts` is a SyntaxError-shaped import failure rather than an
|
|
87
|
+
agent. Same substitution `elan new` already makes for the module it
|
|
88
|
+
scaffolds, so the two agree.
|
|
89
|
+
"""
|
|
90
|
+
return slug.replace("-", "_")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def generate(project: Project, slugs: list[str]) -> Path:
|
|
94
|
+
"""Write one shim per target. Returns the directory to point ADK at."""
|
|
95
|
+
root = project.root / SHIM_ROOT
|
|
96
|
+
# REMOVED FIRST, so a target that was deleted or renamed does not linger
|
|
97
|
+
# and get served alongside the ones that still exist.
|
|
98
|
+
if root.exists():
|
|
99
|
+
shutil.rmtree(root)
|
|
100
|
+
root.mkdir(parents=True)
|
|
101
|
+
(project.root / ".elan" / ".gitignore").write_text(SELF_IGNORE)
|
|
102
|
+
|
|
103
|
+
for slug in slugs:
|
|
104
|
+
package = root / module_name(slug)
|
|
105
|
+
package.mkdir()
|
|
106
|
+
# A REGULAR PACKAGE, not a namespace one. Without this the directory
|
|
107
|
+
# is still importable, and also shadowable by anything else on the
|
|
108
|
+
# path with the same name.
|
|
109
|
+
(package / "__init__.py").write_text("")
|
|
110
|
+
(package / "agent.py").write_text(
|
|
111
|
+
SHIM.format(slug=slug, root=project.targets_root))
|
|
112
|
+
return root
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def argv_for(args: list[str], root: Path, slugs: list[str]) -> list[str]:
|
|
116
|
+
"""What to pass ADK, given what the developer typed.
|
|
117
|
+
|
|
118
|
+
TWO RULES AND A PASSTHROUGH, deliberately not a general rewriter. Anything
|
|
119
|
+
cleverer — substituting every argument that happens to match a slug, say —
|
|
120
|
+
would eventually rewrite a filename or an option value, and a wrapper that
|
|
121
|
+
edits arguments unpredictably is worse than one that does nothing.
|
|
122
|
+
"""
|
|
123
|
+
if not args:
|
|
124
|
+
return []
|
|
125
|
+
command = args[0]
|
|
126
|
+
if command in DIRECTORY_COMMANDS:
|
|
127
|
+
# APPENDED, so any option the developer passed still applies.
|
|
128
|
+
return [*args, str(root)]
|
|
129
|
+
if command in TARGET_COMMANDS and len(args) > 1:
|
|
130
|
+
# ONLY AN EXACT SLUG IS REPLACED. The slug they typed is the one the
|
|
131
|
+
# platform uses everywhere else; the directory is its module-safe
|
|
132
|
+
# spelling, which is an implementation detail they should not have to
|
|
133
|
+
# know.
|
|
134
|
+
return [command, *(str(root / module_name(a)) if a in slugs else a
|
|
135
|
+
for a in args[1:])]
|
|
136
|
+
return list(args)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def run(args: list[str], start: Path | None = None,
|
|
140
|
+
python: str | None = None) -> int:
|
|
141
|
+
if not args:
|
|
142
|
+
print("error: `elan adk` passes its arguments to ADK's own CLI.\n\n"
|
|
143
|
+
" elan adk web the dev UI, every target in this repo\n"
|
|
144
|
+
" elan adk run <slug> one target, in the terminal\n"
|
|
145
|
+
" elan adk --help everything else ADK offers")
|
|
146
|
+
return 2
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
project = find(start)
|
|
150
|
+
except ProjectError as exc:
|
|
151
|
+
print(f"error: {exc}")
|
|
152
|
+
return 2
|
|
153
|
+
|
|
154
|
+
chosen, why = interpreter(project.targets_root, python)
|
|
155
|
+
# THE SAME INTERPRETER, so `adk` sees the repo's dependencies. A globally
|
|
156
|
+
# installed `adk` would import the tenant's modules with whatever happened
|
|
157
|
+
# to be on PATH, and fail on their first third-party import.
|
|
158
|
+
adk = Path(chosen).parent / "adk"
|
|
159
|
+
if not adk.exists():
|
|
160
|
+
print(f"error: no `adk` beside {chosen}\n({why})\n\n"
|
|
161
|
+
"ADK comes with the platform runtime, so this usually means the\n"
|
|
162
|
+
"environment was never created:\n\n"
|
|
163
|
+
" elan env")
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
manifests = read_manifests(project.targets_root, python=python)
|
|
168
|
+
except ManifestError as exc:
|
|
169
|
+
print(f"error: {exc}")
|
|
170
|
+
return 2
|
|
171
|
+
|
|
172
|
+
slugs = sorted(m["slug"] for m in manifests)
|
|
173
|
+
if not slugs:
|
|
174
|
+
print("error: this repo declares no targets, so there is nothing to run.")
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
root = generate(project, slugs)
|
|
178
|
+
print(f"targets {', '.join(slugs)}")
|
|
179
|
+
print(f"shims {root}")
|
|
180
|
+
|
|
181
|
+
argv = [str(adk), *argv_for(args, root, slugs)]
|
|
182
|
+
print(f"running {' '.join(argv[1:])}")
|
|
183
|
+
print(flush=True)
|
|
184
|
+
return subprocess.run(argv, cwd=project.root, env=_environment()).returncode
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _environment() -> dict[str, str]:
|
|
188
|
+
"""ADK's own environment, plus the one thing it must not inherit.
|
|
189
|
+
|
|
190
|
+
`TARGET_SLUG` NAMES WHICH AGENT A POD RUNS, and a developer who has it set
|
|
191
|
+
— from a `.env`, or from having run the runtime by hand — would get that
|
|
192
|
+
one target for every shim, because `Settings` reads it. The shim passes
|
|
193
|
+
its slug explicitly; removing this makes sure nothing overrides it.
|
|
194
|
+
"""
|
|
195
|
+
env = dict(os.environ)
|
|
196
|
+
env.pop("TARGET_SLUG", None)
|
|
197
|
+
return env
|
elan_cli/auth.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""`elan login` — the device flow, and where the refresh token lives.
|
|
2
|
+
|
|
3
|
+
A DEVICE FLOW BECAUSE A TERMINAL HAS NO REDIRECT URI. The CLI asks the issuer
|
|
4
|
+
for a code, prints a URL and a short user code, and polls until a human has
|
|
5
|
+
approved it in whatever browser they already have — possibly on another machine
|
|
6
|
+
entirely, which is the case that makes this better than a loopback redirect.
|
|
7
|
+
|
|
8
|
+
NOT A PERSONAL ACCESS TOKEN, which is what most tools reach for. A PAT is a
|
|
9
|
+
long-lived bearer secret that has to be pasted, ends up in a shell history and
|
|
10
|
+
a dotfile, and carries its owner's whole authority until somebody remembers to
|
|
11
|
+
revoke it. This yields a short-lived access token and a refresh token.
|
|
12
|
+
|
|
13
|
+
THE REFRESH TOKEN GOES TO THE OS KEYRING AND NEVER TO A FILE. That is the one
|
|
14
|
+
non-negotiable part: a token under `~/.config` is readable by every process
|
|
15
|
+
that runs as the developer, backed up to wherever their home directory is
|
|
16
|
+
backed up, and synced to whatever syncs it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from elan_agentkit_contracts.oauth import token_scope
|
|
28
|
+
|
|
29
|
+
from elan_cli.config import Config
|
|
30
|
+
|
|
31
|
+
#: What the keyring entry is called. Stable, because changing it orphans every
|
|
32
|
+
#: credential already stored rather than migrating it.
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
KEYRING_SERVICE = "elan-cli"
|
|
36
|
+
|
|
37
|
+
def scopes(config: Config) -> str:
|
|
38
|
+
"""What to ask the issuer for, MOSTLY ANSWERED BY THE PLATFORM.
|
|
39
|
+
|
|
40
|
+
`offline_access` IS THE CLI'S OWN and is not in the served scope: it is what
|
|
41
|
+
makes the issuer return a refresh token at all, and without it every command
|
|
42
|
+
an hour later needs a browser again. A `client_credentials` caller must not
|
|
43
|
+
ask for it, so it is added here rather than published to everybody.
|
|
44
|
+
|
|
45
|
+
THE REST IS `token_scope` FROM `GET /v1/auth`, including the audience term
|
|
46
|
+
that says who the token is FOR — without which it is addressed to nobody in
|
|
47
|
+
particular and the service's audience check can never pass. Composed locally
|
|
48
|
+
only against a platform that predates the field.
|
|
49
|
+
"""
|
|
50
|
+
base = config.token_scope or token_scope(config.audience)
|
|
51
|
+
return f"{base} offline_access"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuthError(Exception):
|
|
55
|
+
"""Something about signing in, phrased for the person at the terminal."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Tokens:
|
|
60
|
+
access_token: str
|
|
61
|
+
refresh_token: str
|
|
62
|
+
#: Unix time. Compared with a margin rather than exactly — see `valid`.
|
|
63
|
+
expires_at: float
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def valid(self) -> bool:
|
|
67
|
+
"""A MARGIN, NOT AN EXACT COMPARISON. A token that expires during the
|
|
68
|
+
request it was attached to fails as a 401 the CLI cannot distinguish
|
|
69
|
+
from a revoked session, so it is refreshed while it still works."""
|
|
70
|
+
return time.time() < self.expires_at - 60
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _keyring():
|
|
74
|
+
try:
|
|
75
|
+
import keyring
|
|
76
|
+
except ImportError as exc: # pragma: no cover - depends on the install
|
|
77
|
+
raise AuthError(
|
|
78
|
+
"the `keyring` package is not installed, and the CLI will not "
|
|
79
|
+
"write a credential to a file instead. Install it with "
|
|
80
|
+
"`pip install keyring`."
|
|
81
|
+
) from exc
|
|
82
|
+
return keyring
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def store(issuer: str, refresh_token: str) -> None:
|
|
86
|
+
"""Keep the refresh token, and DROP ANY CACHED ACCESS TOKEN WITH IT.
|
|
87
|
+
|
|
88
|
+
A rotated refresh token means a new session; an access token minted under
|
|
89
|
+
the previous one is not obviously invalid and would be used until it
|
|
90
|
+
expired. Writing one without clearing the other is how a cache outlives
|
|
91
|
+
the thing it was derived from.
|
|
92
|
+
"""
|
|
93
|
+
_keyring().set_password(KEYRING_SERVICE, issuer, refresh_token)
|
|
94
|
+
_forget_access(issuer)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stored(issuer: str) -> str | None:
|
|
98
|
+
return _keyring().get_password(KEYRING_SERVICE, issuer)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── THE ACCESS TOKEN IS CACHED TOO, AND THAT IS A LATENCY FIX ──────────────
|
|
102
|
+
#
|
|
103
|
+
# `Api.token()` holds its tokens on the INSTANCE, so a fresh process always
|
|
104
|
+
# starts with none — and every `elan` command therefore paid a full OAuth
|
|
105
|
+
# refresh before doing any work: a keyring read, a TLS handshake to the
|
|
106
|
+
# issuer and a round trip, on `invoke`, on `agents`, on `status`. Measured at
|
|
107
|
+
# roughly a second, every time, against a command whose own work is one API
|
|
108
|
+
# call.
|
|
109
|
+
#
|
|
110
|
+
# `Tokens.valid` already existed and had nothing persisted to check against.
|
|
111
|
+
#
|
|
112
|
+
# SAME KEYRING, AND NO WEAKER FOR IT. The refresh token is already there and
|
|
113
|
+
# is the stronger credential — it MINTS access tokens, for as long as the
|
|
114
|
+
# session lives, while an access token expires on its own. Anything that can
|
|
115
|
+
# read one can read the other.
|
|
116
|
+
_ACCESS_SUFFIX = "#access"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _access_key(issuer: str) -> str:
|
|
120
|
+
return f"{issuer}{_ACCESS_SUFFIX}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cache_access(issuer: str, tokens: Tokens) -> None:
|
|
124
|
+
"""Remember an access token until it expires."""
|
|
125
|
+
try:
|
|
126
|
+
_keyring().set_password(
|
|
127
|
+
KEYRING_SERVICE, _access_key(issuer),
|
|
128
|
+
json.dumps({"access_token": tokens.access_token,
|
|
129
|
+
"expires_at": tokens.expires_at}))
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
# A CACHE THAT CANNOT BE WRITTEN IS NOT AN ERROR, it is a slow
|
|
132
|
+
# command. Some keyring backends are read-only or locked, and
|
|
133
|
+
# refusing to run would trade a second of latency for an outage.
|
|
134
|
+
logger.debug("could not cache the access token", exc_info=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cached_access(issuer: str) -> Tokens | None:
|
|
138
|
+
"""The cached access token, if there is one and it is still good."""
|
|
139
|
+
try:
|
|
140
|
+
raw = _keyring().get_password(KEYRING_SERVICE, _access_key(issuer))
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
return None
|
|
143
|
+
if not raw:
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
held = json.loads(raw)
|
|
147
|
+
found = Tokens(access_token=held["access_token"], refresh_token="",
|
|
148
|
+
expires_at=float(held["expires_at"]))
|
|
149
|
+
except (ValueError, KeyError, TypeError):
|
|
150
|
+
# A CACHE THAT CANNOT BE READ IS A CACHE MISS. The format may have
|
|
151
|
+
# changed under an older CLI; refreshing is always correct.
|
|
152
|
+
return None
|
|
153
|
+
return found if found.valid else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _forget_access(issuer: str) -> None:
|
|
157
|
+
try:
|
|
158
|
+
keyring = _keyring()
|
|
159
|
+
if keyring.get_password(KEYRING_SERVICE, _access_key(issuer)):
|
|
160
|
+
keyring.delete_password(KEYRING_SERVICE, _access_key(issuer))
|
|
161
|
+
except Exception: # noqa: BLE001
|
|
162
|
+
logger.debug("could not clear the cached access token", exc_info=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def forget(issuer: str) -> bool:
|
|
166
|
+
"""`elan logout`. BOTH CREDENTIALS, or the session outlives the logout:
|
|
167
|
+
a cached access token keeps working until it expires, which is the one
|
|
168
|
+
thing somebody signing out is asking not to happen."""
|
|
169
|
+
keyring = _keyring()
|
|
170
|
+
_forget_access(issuer)
|
|
171
|
+
if keyring.get_password(KEYRING_SERVICE, issuer) is None:
|
|
172
|
+
return False
|
|
173
|
+
keyring.delete_password(KEYRING_SERVICE, issuer)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def platform_auth(api: str, *, client: httpx.Client | None = None) -> dict:
|
|
178
|
+
"""Ask the PLATFORM where to sign in.
|
|
179
|
+
|
|
180
|
+
ONE URL INSTEAD OF FOUR FLAGS. A developer cannot be expected to know a
|
|
181
|
+
generated OIDC project id, and every value this returns is public anyway:
|
|
182
|
+
the issuer and audience appear in every token minted, and a public client's
|
|
183
|
+
id is public by construction — a secret shipped in every copy of a CLI is
|
|
184
|
+
not a secret.
|
|
185
|
+
"""
|
|
186
|
+
owned = client is None
|
|
187
|
+
client = client or httpx.Client(timeout=30.0)
|
|
188
|
+
try:
|
|
189
|
+
response = client.get(f"{api}/v1/auth")
|
|
190
|
+
except httpx.HTTPError as exc:
|
|
191
|
+
raise AuthError(f"cannot reach {api}: {exc}") from exc
|
|
192
|
+
finally:
|
|
193
|
+
if owned:
|
|
194
|
+
client.close()
|
|
195
|
+
|
|
196
|
+
if response.status_code == 503:
|
|
197
|
+
raise AuthError(
|
|
198
|
+
f"{api} is not finished being set up — it has no sign-in "
|
|
199
|
+
"configuration yet. That is a platform-side problem, not "
|
|
200
|
+
"something this CLI can work around."
|
|
201
|
+
)
|
|
202
|
+
if response.status_code == 404:
|
|
203
|
+
raise AuthError(
|
|
204
|
+
f"{api} does not look like an Elan platform: it has no /v1/auth. "
|
|
205
|
+
"Check the URL."
|
|
206
|
+
)
|
|
207
|
+
if response.status_code != 200:
|
|
208
|
+
raise AuthError(
|
|
209
|
+
f"{api} answered HTTP {response.status_code} when asked where to "
|
|
210
|
+
f"sign in: {response.text[:200]}")
|
|
211
|
+
payload = response.json()
|
|
212
|
+
missing = [f for f in ("issuer", "client_id") if not payload.get(f)]
|
|
213
|
+
if missing:
|
|
214
|
+
raise AuthError(
|
|
215
|
+
f"{api} answered without {', '.join(missing)}, so there is "
|
|
216
|
+
"nothing to sign in against.")
|
|
217
|
+
return payload
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def discover(config: Config, *, client: httpx.Client | None = None) -> dict:
|
|
221
|
+
"""The issuer's own endpoints, asked for rather than assumed.
|
|
222
|
+
|
|
223
|
+
Hardcoding `/oauth/v2/device_authorization` would work against this issuer
|
|
224
|
+
and silently fail against any other — and the discovery document exists so
|
|
225
|
+
a client never has to guess.
|
|
226
|
+
"""
|
|
227
|
+
owned = client is None
|
|
228
|
+
client = client or httpx.Client(timeout=30.0)
|
|
229
|
+
try:
|
|
230
|
+
response = client.get(f"{config.issuer}/.well-known/openid-configuration")
|
|
231
|
+
if response.status_code != 200:
|
|
232
|
+
raise AuthError(
|
|
233
|
+
f"{config.issuer} did not serve a discovery document "
|
|
234
|
+
f"(HTTP {response.status_code}). Is the issuer right?"
|
|
235
|
+
)
|
|
236
|
+
return response.json()
|
|
237
|
+
except httpx.HTTPError as exc:
|
|
238
|
+
raise AuthError(f"cannot reach {config.issuer}: {exc}") from exc
|
|
239
|
+
finally:
|
|
240
|
+
if owned:
|
|
241
|
+
client.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def begin(config: Config, *, client: httpx.Client | None = None) -> dict:
|
|
245
|
+
"""Ask for a device code. Returns the issuer's response verbatim."""
|
|
246
|
+
document = discover(config, client=client)
|
|
247
|
+
endpoint = document.get("device_authorization_endpoint")
|
|
248
|
+
if not endpoint:
|
|
249
|
+
raise AuthError(
|
|
250
|
+
f"{config.issuer} does not advertise a device authorization "
|
|
251
|
+
"endpoint, so `elan login` cannot work against it. The CLI needs "
|
|
252
|
+
"one because a terminal has no redirect URI."
|
|
253
|
+
)
|
|
254
|
+
owned = client is None
|
|
255
|
+
client = client or httpx.Client(timeout=30.0)
|
|
256
|
+
try:
|
|
257
|
+
response = client.post(endpoint, data={
|
|
258
|
+
"client_id": config.client_id,
|
|
259
|
+
"scope": scopes(config),
|
|
260
|
+
})
|
|
261
|
+
if response.status_code >= 400:
|
|
262
|
+
raise AuthError(
|
|
263
|
+
f"the issuer refused the device request "
|
|
264
|
+
f"(HTTP {response.status_code}): {response.text[:300]}\n"
|
|
265
|
+
" A 400 here usually means the CLI's client id is not "
|
|
266
|
+
"configured for the device_code grant."
|
|
267
|
+
)
|
|
268
|
+
payload = response.json()
|
|
269
|
+
payload["_token_endpoint"] = document["token_endpoint"]
|
|
270
|
+
return payload
|
|
271
|
+
except httpx.HTTPError as exc:
|
|
272
|
+
raise AuthError(f"cannot reach {endpoint}: {exc}") from exc
|
|
273
|
+
finally:
|
|
274
|
+
if owned:
|
|
275
|
+
client.close()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def poll(config: Config, device: dict, *, client: httpx.Client | None = None,
|
|
279
|
+
sleep=time.sleep, now=time.monotonic) -> Tokens:
|
|
280
|
+
"""Wait for the human, honouring the issuer's OWN pacing.
|
|
281
|
+
|
|
282
|
+
`interval` AND `slow_down` ARE NOT OPTIONAL. Polling faster than the issuer
|
|
283
|
+
asked earns a `slow_down`, and ignoring that earns a refusal — so a login
|
|
284
|
+
that polled on a fixed timer would work until the issuer was busy and then
|
|
285
|
+
fail in a way nobody could reproduce.
|
|
286
|
+
"""
|
|
287
|
+
owned = client is None
|
|
288
|
+
client = client or httpx.Client(timeout=30.0)
|
|
289
|
+
interval = float(device.get("interval", 5))
|
|
290
|
+
deadline = now() + float(device.get("expires_in", 300))
|
|
291
|
+
try:
|
|
292
|
+
while now() < deadline:
|
|
293
|
+
sleep(interval)
|
|
294
|
+
response = client.post(device["_token_endpoint"], data={
|
|
295
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
296
|
+
"device_code": device["device_code"],
|
|
297
|
+
"client_id": config.client_id,
|
|
298
|
+
})
|
|
299
|
+
payload = response.json() if response.content else {}
|
|
300
|
+
if response.status_code == 200:
|
|
301
|
+
return _tokens(payload)
|
|
302
|
+
|
|
303
|
+
error = payload.get("error", "")
|
|
304
|
+
if error == "authorization_pending":
|
|
305
|
+
continue
|
|
306
|
+
if error == "slow_down":
|
|
307
|
+
# The issuer's instruction, followed rather than noted.
|
|
308
|
+
interval += 5
|
|
309
|
+
continue
|
|
310
|
+
if error == "access_denied":
|
|
311
|
+
raise AuthError("the request was denied in the browser.")
|
|
312
|
+
if error == "expired_token":
|
|
313
|
+
break
|
|
314
|
+
raise AuthError(
|
|
315
|
+
f"the issuer refused the token request: "
|
|
316
|
+
f"{error or response.status_code} "
|
|
317
|
+
f"{payload.get('error_description', '')}".strip()
|
|
318
|
+
)
|
|
319
|
+
raise AuthError(
|
|
320
|
+
"the login expired before it was approved. Run `elan login` again."
|
|
321
|
+
)
|
|
322
|
+
finally:
|
|
323
|
+
if owned:
|
|
324
|
+
client.close()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def refresh(config: Config, refresh_token: str, *,
|
|
328
|
+
client: httpx.Client | None = None) -> Tokens:
|
|
329
|
+
"""Trade a refresh token for a fresh access token.
|
|
330
|
+
|
|
331
|
+
A FAILURE HERE IS NOT AN ERROR TO RETRY. A refresh token is rejected
|
|
332
|
+
because the session was revoked, the token was rotated, or it expired — all
|
|
333
|
+
of which mean the same thing to the developer, which is that they have to
|
|
334
|
+
sign in again. So the message says that rather than reporting an OAuth
|
|
335
|
+
error code.
|
|
336
|
+
"""
|
|
337
|
+
document = discover(config, client=client)
|
|
338
|
+
owned = client is None
|
|
339
|
+
client = client or httpx.Client(timeout=30.0)
|
|
340
|
+
try:
|
|
341
|
+
response = client.post(document["token_endpoint"], data={
|
|
342
|
+
"grant_type": "refresh_token",
|
|
343
|
+
"refresh_token": refresh_token,
|
|
344
|
+
"client_id": config.client_id,
|
|
345
|
+
"scope": scopes(config),
|
|
346
|
+
})
|
|
347
|
+
if response.status_code != 200:
|
|
348
|
+
raise AuthError("your session is no longer valid. Run `elan login`.")
|
|
349
|
+
return _tokens(response.json())
|
|
350
|
+
except httpx.HTTPError as exc:
|
|
351
|
+
raise AuthError(f"cannot reach the issuer: {exc}") from exc
|
|
352
|
+
finally:
|
|
353
|
+
if owned:
|
|
354
|
+
client.close()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _tokens(payload: dict) -> Tokens:
|
|
358
|
+
access = payload.get("access_token", "")
|
|
359
|
+
if access.count(".") != 2:
|
|
360
|
+
# THE TRAP ADR-0049 RECORDED, caught on the client side too. Zitadel
|
|
361
|
+
# issues OPAQUE tokens unless the application asks for JWTs, and an
|
|
362
|
+
# opaque token fails at the verifier as MALFORMED — which reads like a
|
|
363
|
+
# CLI bug rather than an application setting. Saying it here names the
|
|
364
|
+
# actual cause.
|
|
365
|
+
raise AuthError(
|
|
366
|
+
"the issuer returned an OPAQUE access token, not a JWT. The "
|
|
367
|
+
"services verify locally against the published keys and cannot "
|
|
368
|
+
"verify an opaque string. The CLI's application needs "
|
|
369
|
+
"`accessTokenType: JWT`."
|
|
370
|
+
)
|
|
371
|
+
return Tokens(
|
|
372
|
+
access_token=access,
|
|
373
|
+
refresh_token=payload.get("refresh_token", ""),
|
|
374
|
+
expires_at=time.time() + float(payload.get("expires_in", 3600)),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def claims(access_token: str) -> dict:
|
|
379
|
+
"""The token's payload, DECODED AND NOT VERIFIED.
|
|
380
|
+
|
|
381
|
+
Used only to show a developer who they are signed in as. The CLI has no
|
|
382
|
+
business verifying a token it did not have to trust — the SERVICE verifies,
|
|
383
|
+
against the issuer's published keys, and that is the check that matters. A
|
|
384
|
+
second verification here would imply this one was load-bearing.
|
|
385
|
+
"""
|
|
386
|
+
import base64
|
|
387
|
+
import json
|
|
388
|
+
|
|
389
|
+
payload = access_token.split(".")[1]
|
|
390
|
+
payload += "=" * (-len(payload) % 4)
|
|
391
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|