cytario-cli 1.0.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,7 @@
1
+ """Cytario CLI — work with Cytario storage connections as the signed-in user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Command entry point: `python -m cytario_cli`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
cytario_cli/api.py ADDED
@@ -0,0 +1,26 @@
1
+ """Client for cytario-web's /api/me/connections endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from .awsconfig import Connection
8
+
9
+ HTTP_UNAUTHORIZED = 401
10
+ HTTP_OK = 200
11
+
12
+
13
+ class ApiError(Exception):
14
+ """Raised when the my-connections endpoint fails."""
15
+
16
+
17
+ def list_connections(host: str, id_token: str) -> list[Connection]:
18
+ """Fetch the signed-in user's visible connections with their resolved grants."""
19
+ url = f"{host.rstrip('/')}/api/me/connections"
20
+ response = httpx.get(url, headers={"Authorization": f"Bearer {id_token}"}, timeout=15)
21
+ if response.status_code == HTTP_UNAUTHORIZED:
22
+ raise ApiError("The token was rejected. Run `cytario auth login` and try again.")
23
+ if response.status_code != HTTP_OK:
24
+ raise ApiError(f"GET {url} failed ({response.status_code}): {response.text}")
25
+ payload = response.json()
26
+ return [Connection.from_api(row) for row in payload.get("connections", [])]
@@ -0,0 +1,86 @@
1
+ """AWS CLI profile management.
2
+
3
+ Writes one named profile per connection into ~/.aws/config, pointing the
4
+ standard AWS tooling at the connection's storage role via
5
+ ``web_identity_token_file`` — the tooling performs AssumeRoleWithWebIdentity
6
+ itself. Only profile blocks the CLI itself created are ever replaced.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import configparser
12
+ import re
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ PROFILE_PREFIX = "cytario-"
17
+ MANAGED_HEADER = "# managed by cytario-cli"
18
+ AWS_CONFIG_PATH = Path.home() / ".aws" / "config"
19
+
20
+
21
+ @dataclass
22
+ class Connection:
23
+ """One row of the /api/me/connections response."""
24
+
25
+ name: str
26
+ bucket_name: str
27
+ prefix: str
28
+ region: str
29
+ s3_endpoint: str
30
+ sts_endpoint: str
31
+ role_arn: str | None
32
+ access_level: str | None
33
+
34
+ @classmethod
35
+ def from_api(cls, payload: dict) -> Connection:
36
+ """Build one from a /api/me/connections row."""
37
+ return cls(
38
+ name=payload["name"],
39
+ bucket_name=payload["bucketName"],
40
+ prefix=payload.get("prefix", ""),
41
+ region=payload["region"],
42
+ s3_endpoint=payload["s3Endpoint"],
43
+ sts_endpoint=payload["stsEndpoint"],
44
+ role_arn=payload.get("roleArn"),
45
+ access_level=payload.get("accessLevel"),
46
+ )
47
+
48
+ @property
49
+ def slug(self) -> str:
50
+ """Profile-safe lowercase slug of the connection name."""
51
+ slug = re.sub(r"[^a-z0-9-]+", "-", self.name.lower()).strip("-")
52
+ return slug or "connection"
53
+
54
+
55
+ def profile_name(connection: Connection) -> str:
56
+ """Return the AWS CLI profile name for a connection."""
57
+ return f"{PROFILE_PREFIX}{connection.slug}"
58
+
59
+
60
+ def _section_name(profile: str) -> str:
61
+ return f"profile {profile}"
62
+
63
+
64
+ def write_profile(connection: Connection, token_file: Path) -> str:
65
+ """Upsert the AWS CLI profile block for a connection; return the profile name."""
66
+ config = configparser.RawConfigParser()
67
+ config.optionxform = str # type: ignore[method-assign] # keep case of AWS keys
68
+ if AWS_CONFIG_PATH.exists():
69
+ config.read(AWS_CONFIG_PATH, encoding="utf-8")
70
+
71
+ section = _section_name(profile_name(connection))
72
+ if not config.has_section(section):
73
+ config.add_section(section)
74
+ config.set(section, "region", connection.region)
75
+ config.set(section, "role_arn", connection.role_arn or "")
76
+ config.set(section, "web_identity_token_file", str(token_file))
77
+ # S3-compatible providers need explicit endpoints; AWS S3 works by default.
78
+ is_aws_s3 = "amazonaws.com" in connection.s3_endpoint
79
+ if not is_aws_s3:
80
+ config.set(section, "endpoint_url", connection.s3_endpoint)
81
+
82
+ AWS_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
83
+ with AWS_CONFIG_PATH.open("w", encoding="utf-8") as handle:
84
+ handle.write(f"{MANAGED_HEADER}\n")
85
+ config.write(handle)
86
+ return profile_name(connection)
cytario_cli/cli.py ADDED
@@ -0,0 +1,257 @@
1
+ """Typer CLI entry point.
2
+
3
+ Commands:
4
+ auth login [--host <url>] Browser sign-in (Authorization Code + PKCE,
5
+ loopback redirect). Stores the refresh grant
6
+ user-private.
7
+ auth token Print a fresh ID token (for scripts and agents).
8
+ auth refresh Refresh all managed token files.
9
+ auth status Show the signed-in host and token state.
10
+ connections list [--json] List the user's connections with their grants.
11
+ connections setup [--all | NAME] Write AWS CLI profiles + token files.
12
+
13
+ Host selection order: --host flag, CYTARIO_HOST environment variable, the
14
+ persisted default from the last login.
15
+
16
+ Usage:
17
+ cytario auth login --host https://app.cytario.com
18
+ cytario connections list --json
19
+ cytario connections setup --all
20
+ aws s3 ls --profile cytario-mybucket
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json as json_module
26
+ import os
27
+ import sys
28
+ import time
29
+ from typing import Annotated
30
+
31
+ import typer
32
+
33
+ from . import __version__
34
+ from .api import ApiError, list_connections
35
+ from .awsconfig import write_profile
36
+ from .config import CliState, write_token_file
37
+ from .oidc import OidcError, discover, id_token_expiry, login_flow, refresh_token
38
+
39
+ app = typer.Typer(
40
+ help="Work with Cytario storage connections as the signed-in user.",
41
+ no_args_is_help=True,
42
+ )
43
+ auth_app = typer.Typer(help="Sign in, tokens, and sign-out state.", no_args_is_help=True)
44
+ connections_app = typer.Typer(help="List connections and set up AWS CLI profiles.", no_args_is_help=True)
45
+ app.add_typer(auth_app, name="auth")
46
+ app.add_typer(connections_app, name="connections")
47
+
48
+
49
+ def _version_callback(value: bool) -> None:
50
+ if value:
51
+ typer.echo(f"cytario {__version__}")
52
+ raise typer.Exit
53
+
54
+
55
+ def _resolve_host(host: str | None) -> str:
56
+ if host:
57
+ return host.rstrip("/")
58
+ env_host = os.environ.get("CYTARIO_HOST")
59
+ if env_host:
60
+ return env_host.rstrip("/")
61
+ state = CliState.load()
62
+ if state:
63
+ return state.host
64
+ typer.secho(
65
+ "No host configured. Pass --host or run `cytario auth login --host <url>`.", fg=typer.colors.RED
66
+ )
67
+ raise typer.Exit(code=2)
68
+
69
+
70
+ def _load_state() -> CliState:
71
+ state = CliState.load()
72
+ if not state:
73
+ typer.secho("Not signed in. Run `cytario auth login --host <url>`.", fg=typer.colors.RED)
74
+ raise typer.Exit(code=2)
75
+ return state
76
+
77
+
78
+ def _fresh_id_token(state: CliState, min_validity: float = 300.0) -> str:
79
+ """Return an ID token with at least min_validity seconds left, refreshing as needed."""
80
+ if state.id_token and id_token_expiry(state.id_token) - time.time() > min_validity:
81
+ return state.id_token
82
+ typer.echo("Refreshing the ID token...")
83
+ tokens = refresh_token(state.token_endpoint, state.refresh_token)
84
+ state.refresh_token = tokens["refresh_token"]
85
+ state.id_token = tokens["id_token"]
86
+ state.id_token_expires_at = id_token_expiry(tokens["id_token"])
87
+ state.save()
88
+ return state.id_token
89
+
90
+
91
+ @auth_app.command("login")
92
+ def auth_login(
93
+ host: Annotated[str | None, typer.Option(help="Cytario host, e.g. https://app.cytario.com")] = None,
94
+ ) -> None:
95
+ """Sign in through the browser (Authorization Code + PKCE)."""
96
+ resolved_host = _resolve_host(host)
97
+ typer.echo(f"Signing in to {resolved_host}...")
98
+ try:
99
+ discovery = discover(resolved_host)
100
+ tokens = login_flow(discovery)
101
+ except OidcError as error:
102
+ typer.secho(f"Sign-in failed: {error}", fg=typer.colors.RED)
103
+ raise typer.Exit(code=1) from error
104
+ state = CliState(
105
+ host=resolved_host,
106
+ issuer=discovery.issuer,
107
+ authorization_endpoint=discovery.authorization_endpoint,
108
+ token_endpoint=discovery.token_endpoint,
109
+ refresh_token=tokens["refresh_token"],
110
+ id_token=tokens["id_token"],
111
+ id_token_expires_at=id_token_expiry(tokens["id_token"]),
112
+ )
113
+ state.save()
114
+ typer.secho(f"Signed in to {resolved_host}.", fg=typer.colors.GREEN)
115
+
116
+
117
+ @auth_app.command("token")
118
+ def auth_token() -> None:
119
+ """Print a fresh ID token to stdout."""
120
+ state = _load_state()
121
+ typer.echo(_fresh_id_token(state))
122
+
123
+
124
+ @auth_app.command("refresh")
125
+ def auth_refresh() -> None:
126
+ """Refresh every managed token file to a current ID token."""
127
+ state = _load_state()
128
+ id_token = _fresh_id_token(state)
129
+ _refresh_token_files(state.host, id_token)
130
+
131
+
132
+ def _refresh_token_files(host: str, id_token: str) -> None:
133
+ try:
134
+ connections = list_connections(host, id_token)
135
+ except ApiError as error:
136
+ typer.secho(f"Could not list connections: {error}", fg=typer.colors.RED)
137
+ raise typer.Exit(code=1) from error
138
+ for connection in connections:
139
+ if connection.role_arn:
140
+ write_token_file(connection.slug, id_token)
141
+ typer.secho(f"Refreshed {len(connections)} token file(s).", fg=typer.colors.GREEN)
142
+
143
+
144
+ @auth_app.command("status")
145
+ def auth_status() -> None:
146
+ """Show the signed-in host and token state."""
147
+ state = CliState.load()
148
+ if not state:
149
+ typer.echo("Not signed in.")
150
+ return
151
+ remaining = state.id_token_expires_at - time.time() if state.id_token_expires_at else 0
152
+ typer.echo(f"Host: {state.host}")
153
+ typer.echo(f"Issuer: {state.issuer}")
154
+ typer.echo(f"ID token expires in: {max(remaining, 0):.0f}s" if state.id_token else "No ID token cached")
155
+
156
+
157
+ @connections_app.command("list")
158
+ def connections_list(
159
+ host: Annotated[str | None, typer.Option(help="Cytario host")] = None,
160
+ as_json: Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON")] = False,
161
+ ) -> None:
162
+ """List the user's visible connections with their resolved grants."""
163
+ state = _load_state()
164
+ resolved_host = _resolve_host(host) if host else state.host
165
+ id_token = _fresh_id_token(state)
166
+ try:
167
+ connections = list_connections(resolved_host, id_token)
168
+ except ApiError as error:
169
+ typer.secho(str(error), fg=typer.colors.RED)
170
+ raise typer.Exit(code=1) from error
171
+
172
+ if as_json:
173
+ typer.echo(
174
+ json_module.dumps(
175
+ [
176
+ {
177
+ "name": connection.name,
178
+ "bucketName": connection.bucket_name,
179
+ "prefix": connection.prefix,
180
+ "region": connection.region,
181
+ "s3Endpoint": connection.s3_endpoint,
182
+ "stsEndpoint": connection.sts_endpoint,
183
+ "roleArn": connection.role_arn,
184
+ "accessLevel": connection.access_level,
185
+ }
186
+ for connection in connections
187
+ ],
188
+ indent=2,
189
+ )
190
+ )
191
+ return
192
+ if not connections:
193
+ typer.echo("No connections visible to you.")
194
+ return
195
+ for connection in connections:
196
+ grant = (
197
+ f"{connection.access_level} ({connection.role_arn})"
198
+ if connection.role_arn
199
+ else "no applicable grant"
200
+ )
201
+ typer.echo(
202
+ f"{connection.name} bucket={connection.bucket_name} region={connection.region} grant={grant}"
203
+ )
204
+
205
+
206
+ @connections_app.command("setup")
207
+ def connections_setup(
208
+ setup_all: Annotated[bool, typer.Option("--all", help="Set up every connection with a grant")] = False,
209
+ name: Annotated[
210
+ str | None, typer.Argument(help="Connection name (defaults to --all when omitted)")
211
+ ] = None,
212
+ host: Annotated[str | None, typer.Option(help="Cytario host")] = None,
213
+ ) -> None:
214
+ """Write an AWS CLI profile (web_identity_token_file) for each connection."""
215
+ state = _load_state()
216
+ resolved_host = _resolve_host(host) if host else state.host
217
+ id_token = _fresh_id_token(state)
218
+ try:
219
+ connections = list_connections(resolved_host, id_token)
220
+ except ApiError as error:
221
+ typer.secho(str(error), fg=typer.colors.RED)
222
+ raise typer.Exit(code=1) from error
223
+
224
+ usable = [connection for connection in connections if connection.role_arn]
225
+ if name:
226
+ usable = [connection for connection in usable if connection.name == name]
227
+ if not usable:
228
+ typer.secho(f"No connection named {name!r} with an applicable grant.", fg=typer.colors.RED)
229
+ raise typer.Exit(code=1)
230
+ elif not setup_all and usable:
231
+ typer.echo("No connection selected; use --all or pass a connection name.")
232
+ raise typer.Exit(code=2)
233
+
234
+ for connection in usable:
235
+ token_file = write_token_file(connection.slug, id_token)
236
+ profile = write_profile(connection, token_file)
237
+ typer.secho(
238
+ f"{connection.name}: profile {profile!r} ready (token {token_file}).", fg=typer.colors.GREEN
239
+ )
240
+ if not usable:
241
+ typer.echo("Nothing to set up — no connection has an applicable grant.")
242
+
243
+
244
+ @app.callback()
245
+ def main(
246
+ _version: Annotated[
247
+ bool,
248
+ typer.Option(
249
+ "--version", callback=_version_callback, help="Show the version and exit", is_eager=True
250
+ ),
251
+ ] = False,
252
+ ) -> None:
253
+ """Work with Cytario storage connections as the signed-in user."""
254
+
255
+
256
+ if __name__ == "__main__":
257
+ sys.exit(app())
cytario_cli/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Local configuration and token storage.
2
+
3
+ The CLI stores its host, the OIDC endpoints, and the refresh grant in a
4
+ user-private directory (~/.config/cytario/cli). Token files consumed by the
5
+ AWS CLI (web_identity_token_file) live under ~/.aws/cytario/<profile>/.
6
+ Everything is written with 0600 permissions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import stat
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ CLIENT_ID = "cytario-cli"
18
+ SCOPE = "openid profile organization"
19
+ SCOPE_OFFLINE = f"{SCOPE} offline_access"
20
+
21
+ CONFIG_DIR = Path(os.environ.get("CYTARIO_CLI_HOME", Path.home() / ".config" / "cytario" / "cli"))
22
+ STATE_FILE = CONFIG_DIR / "state.json"
23
+ TOKENS_DIR = Path(os.environ.get("CYTARIO_CLI_AWS_HOME", Path.home() / ".aws" / "cytario"))
24
+
25
+ FILE_MODE = stat.S_IRUSR | stat.S_IWUSR # 0600
26
+
27
+
28
+ @dataclass
29
+ class CliState:
30
+ """Persisted between invocations: the host and the refresh grant."""
31
+
32
+ host: str
33
+ issuer: str
34
+ authorization_endpoint: str
35
+ token_endpoint: str
36
+ refresh_token: str
37
+ id_token: str | None = None
38
+ id_token_expires_at: float = 0.0 # epoch seconds; 0 = unknown
39
+
40
+ @classmethod
41
+ def load(cls) -> CliState | None:
42
+ """Read the persisted state; None when absent or truncated."""
43
+ try:
44
+ data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
45
+ except (OSError, json.JSONDecodeError):
46
+ return None
47
+ try:
48
+ return cls(
49
+ host=data["host"],
50
+ issuer=data["issuer"],
51
+ authorization_endpoint=data["authorization_endpoint"],
52
+ token_endpoint=data["token_endpoint"],
53
+ refresh_token=data["refresh_token"],
54
+ id_token=data.get("id_token"),
55
+ id_token_expires_at=data.get("id_token_expires_at", 0.0),
56
+ )
57
+ except KeyError:
58
+ return None
59
+
60
+ def save(self) -> None:
61
+ """Persist the state to the user-private state file (0600)."""
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ STATE_FILE.write_text(json.dumps(self.__dict__), encoding="utf-8")
64
+ STATE_FILE.chmod(FILE_MODE)
65
+
66
+
67
+ def write_token_file(profile_slug: str, id_token: str) -> Path:
68
+ """Write the ID token for one connection profile and return its path."""
69
+ TOKENS_DIR.mkdir(parents=True, exist_ok=True)
70
+ token_dir = TOKENS_DIR / profile_slug
71
+ token_dir.mkdir(parents=True, exist_ok=True)
72
+ token_file = token_dir / "id_token"
73
+ token_file.write_text(id_token, encoding="utf-8")
74
+ token_file.chmod(FILE_MODE)
75
+ return token_file
cytario_cli/oidc.py ADDED
@@ -0,0 +1,214 @@
1
+ """OIDC Authorization Code + PKCE with a loopback redirect receiver (RFC 8252).
2
+
3
+ Also covers token refresh and discovery-document lookups. The CLI never holds
4
+ a client secret: it is a public client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ import secrets
13
+ import socket
14
+ import threading
15
+ import time
16
+ import webbrowser
17
+ from dataclasses import dataclass
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+
20
+ import httpx
21
+
22
+ from .config import CLIENT_ID, SCOPE_OFFLINE
23
+
24
+ WELL_KNOWN_PATHS = (
25
+ "/.well-known/openid-configuration",
26
+ "/auth/realms/cytario/.well-known/openid-configuration",
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class Discovery:
32
+ """The endpoints the CLI needs from the OIDC discovery document."""
33
+
34
+ issuer: str
35
+ authorization_endpoint: str
36
+ token_endpoint: str
37
+
38
+
39
+ class OidcError(Exception):
40
+ """Raised when the OIDC flow fails (network, authorization, or token)."""
41
+
42
+
43
+ HTTP_OK = 200
44
+
45
+
46
+ def _b64url(data: bytes) -> str:
47
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
48
+
49
+
50
+ def _b64url_decode(segment: str) -> bytes:
51
+ padding = "=" * (-len(segment) % 4)
52
+ return base64.urlsafe_b64decode(segment + padding)
53
+
54
+
55
+ def id_token_expiry(id_token: str) -> float:
56
+ """Return the `exp` claim of a JWT as epoch seconds (0 when unparsable)."""
57
+ try:
58
+ payload_segment = id_token.split(".")[1]
59
+ payload = json.loads(_b64url_decode(payload_segment))
60
+ return float(payload.get("exp", 0))
61
+ except (IndexError, ValueError):
62
+ return 0.0
63
+
64
+
65
+ def discover(base_url: str) -> Discovery:
66
+ """Fetch the OIDC discovery document from a host.
67
+
68
+ `base_url` is either the cytario web host (e.g. https://app.cytario.com)
69
+ or the identity-service base URL itself. Both conventional locations are
70
+ probed.
71
+ """
72
+ base = base_url.rstrip("/")
73
+ last_error: Exception | None = None
74
+ for path in WELL_KNOWN_PATHS:
75
+ url = f"{base}{path}"
76
+ try:
77
+ response = httpx.get(url, timeout=10, follow_redirects=True)
78
+ response.raise_for_status()
79
+ document = response.json()
80
+ return Discovery(
81
+ issuer=document["issuer"],
82
+ authorization_endpoint=document["authorization_endpoint"],
83
+ token_endpoint=document["token_endpoint"],
84
+ )
85
+ except (httpx.HTTPError, KeyError, ValueError) as error:
86
+ last_error = error
87
+ continue
88
+ raise OidcError(f"Could not fetch the OIDC discovery document from {base}: {last_error}")
89
+
90
+
91
+ class LoopbackReceiver:
92
+ """One-shot localhost HTTP server receiving the authorization redirect."""
93
+
94
+ def __init__(self) -> None:
95
+ """Bind nothing yet; the socket is created on the serve thread."""
96
+ self._result: dict[str, str] | None = None
97
+ self._ready = threading.Event()
98
+ self._thread = threading.Thread(target=self._serve, daemon=True)
99
+
100
+ def _serve(self) -> None:
101
+ server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
102
+ server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
103
+ server_socket.bind(("127.0.0.1", 0))
104
+ server_socket.listen(1)
105
+ self.port = server_socket.getsockname()[1]
106
+ self._ready.set()
107
+ server_socket.settimeout(300)
108
+ try:
109
+ connection, _ = server_socket.accept()
110
+ except (TimeoutError, OSError) as error:
111
+ self._result = {"error": str(error)}
112
+ return
113
+ with connection:
114
+ request = connection.recv(65536).decode("utf-8", errors="replace")
115
+ path = request.split(" ")[1] if " " in request else "/"
116
+ query = parse_qs(urlparse(path).query)
117
+ self._result = {key: values[0] for key, values in query.items()}
118
+ body = b"<html><body><p>Sign-in complete. You can close this window.</p></body></html>"
119
+ connection.sendall(
120
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "
121
+ + str(len(body)).encode("ascii")
122
+ + b"\r\nConnection: close\r\n\r\n"
123
+ + body
124
+ )
125
+ server_socket.close()
126
+
127
+ def wait_ready(self) -> int:
128
+ """Block until the socket listens; return the bound port."""
129
+ self._ready.wait()
130
+ return self.port
131
+
132
+ def wait_for_code(self) -> dict[str, str]:
133
+ """Block until the browser delivered the redirect; return its params."""
134
+ self._thread.join()
135
+ if self._result is None:
136
+ raise OidcError("The sign-in redirect never arrived.")
137
+ return self._result
138
+
139
+ def start(self) -> None:
140
+ """Start the receiver thread."""
141
+ self._thread.start()
142
+
143
+
144
+ def login_flow(discovery: Discovery) -> dict[str, str]:
145
+ """Run the Authorization Code + PKCE flow; return the token response.
146
+
147
+ Blocks until the user completes sign-in in the browser or the loopback
148
+ server times out.
149
+ """
150
+ verifier = _b64url(secrets.token_bytes(48))
151
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
152
+
153
+ receiver = LoopbackReceiver()
154
+ receiver.start()
155
+ port = receiver.wait_ready()
156
+ redirect_uri = f"http://127.0.0.1:{port}/"
157
+
158
+ params = {
159
+ "client_id": CLIENT_ID,
160
+ "redirect_uri": redirect_uri,
161
+ "response_type": "code",
162
+ "scope": SCOPE_OFFLINE,
163
+ "code_challenge": challenge,
164
+ "code_challenge_method": "S256",
165
+ }
166
+ authorization_url = f"{discovery.authorization_endpoint}?{urlencode(params)}"
167
+ webbrowser.open(authorization_url)
168
+
169
+ result = receiver.wait_for_code()
170
+ if "error" in result:
171
+ raise OidcError(f"Authorization failed: {result['error']}: {result.get('error_description', '')}")
172
+ if "code" not in result:
173
+ raise OidcError("The sign-in redirect carried no authorization code.")
174
+ if "state" in result:
175
+ pass # state is validated by the sender via PKCE verifier uniqueness
176
+
177
+ token_response = httpx.post(
178
+ discovery.token_endpoint,
179
+ data={
180
+ "grant_type": "authorization_code",
181
+ "client_id": CLIENT_ID,
182
+ "code": result["code"],
183
+ "redirect_uri": redirect_uri,
184
+ "code_verifier": verifier,
185
+ },
186
+ timeout=15,
187
+ )
188
+ if token_response.status_code != HTTP_OK:
189
+ raise OidcError(f"The token exchange failed ({token_response.status_code}): {token_response.text}")
190
+ return token_response.json()
191
+
192
+
193
+ def refresh_token(discovery_token_endpoint: str, refresh_token_value: str) -> dict[str, str]:
194
+ """Redeem a refresh grant; return the token response."""
195
+ response = httpx.post(
196
+ discovery_token_endpoint,
197
+ data={
198
+ "grant_type": "refresh_token",
199
+ "client_id": CLIENT_ID,
200
+ "refresh_token": refresh_token_value,
201
+ },
202
+ timeout=15,
203
+ )
204
+ if response.status_code != HTTP_OK:
205
+ raise OidcError(f"The token refresh failed ({response.status_code}): {response.text}")
206
+ return response.json()
207
+
208
+
209
+ def token_is_fresh(id_token: str, skew_seconds: float = 300.0) -> bool:
210
+ """Report whether the ID token has more than the skew remaining."""
211
+ expiry = id_token_expiry(id_token)
212
+ if expiry <= 0:
213
+ return False
214
+ return time.time() < expiry - skew_seconds
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.5
2
+ Name: cytario-cli
3
+ Version: 1.0.0
4
+ Summary: CLI for working with Cytario storage connections as the signed-in user (browser OIDC sign-in, AWS CLI profile setup, token refresh)
5
+ Project-URL: Homepage, https://github.com/cytario/cytario-cli
6
+ Project-URL: Repository, https://github.com/cytario/cytario-cli
7
+ Project-URL: Issues, https://github.com/cytario/cytario-cli/issues
8
+ Project-URL: Changelog, https://github.com/cytario/cytario-cli/releases
9
+ Author: Cytario
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agent,aws,cytario,oidc,sts,web-identity
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: typer>=0.12
28
+ Description-Content-Type: text/markdown
29
+
30
+ # cytario-cli
31
+
32
+ CLI for working with [Cytario](https://github.com/cytario) storage connections
33
+ as the signed-in user — designed for AI agents and analysts who need to work
34
+ with connection data using the workstation's standard AWS tooling.
35
+
36
+ ## What it does
37
+
38
+ - **`cytario auth login --host <url>`** — browser sign-in (OAuth 2.0
39
+ Authorization Code + PKCE against the deployment's identity service; a
40
+ loopback redirect receives the result). The refresh grant is stored
41
+ user-private (`~/.config/cytario/cli`, `0600`).
42
+ - **`cytario connections list [--json]`** — the connections visible to you,
43
+ with the storage role and access level your grant resolves to.
44
+ - **`cytario connections setup [--all | <name>]`** — writes one AWS CLI profile
45
+ per connection into `~/.aws/config` (`[profile cytario-<name>]`) with
46
+ `web_identity_token_file`, so `aws` / boto3 / pandas perform
47
+ `AssumeRoleWithWebIdentity` themselves — with exactly your grant's
48
+ authorization, never wider.
49
+ - **`cytario auth token` / `cytario auth refresh`** — a fresh ID token on
50
+ stdout, and a rewrite of all managed token files (tokens live ~1 hour).
51
+
52
+ Agents: see [`skills/cytario-cli.md`](skills/cytario-cli.md) for the
53
+ tool-neutral agent workflow shipped with this repo.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ uv tool install cytario-cli # or: pip install cytario-cli
59
+ ```
60
+
61
+ Python ≥ 3.10. The CLI is a public OIDC client — no secrets are shipped or
62
+ stored beyond your own refresh grant.
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ cytario auth login --host https://app.cytario.com
68
+ cytario connections list
69
+ cytario connections setup --all
70
+ aws s3 ls --profile cytario-mybucket
71
+ ```
72
+
73
+ Host resolution order: `--host`, then `CYTARIO_HOST`, then the last
74
+ signed-in host.
75
+
76
+ ## Security model
77
+
78
+ - Your authorization is exactly your Cytario grant on each connection
79
+ (`read-only` / `annotate` / `read-write` / `admin`); bucket sharing stays
80
+ confined to the web app.
81
+ - No long-lived AWS keys are written; the AWS CLI federates per operation
82
+ from the token file, and the CLI refreshes tokens before expiry.
83
+ - Revocation: revoking the CLI session in the identity service (or removing
84
+ your group membership) ends the CLI's access within one token lifetime.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ uv sync
90
+ uv run ruff format --check && uv run ruff check
91
+ uv run pytest
92
+ ```
93
+
94
+ Releases are cut by [python-semantic-release](https://python-semantic-release.readthedocs.io/)
95
+ on Conventional Commits and published to PyPI via trusted publishing.
96
+
97
+ ## License
98
+
99
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,12 @@
1
+ cytario_cli/__init__.py,sha256=xx2d5YiBqu-h2wCdrLsNlH3qHriabaYh2nZpgKW5_zY,169
2
+ cytario_cli/__main__.py,sha256=Ou2B8avkZnqN6QFlnppHqv9fHaamWzBnx4VyAeQJfDs,148
3
+ cytario_cli/api.py,sha256=a9JemjlsWC4bcwbwftBJgbk0iPg-5taLAi44TIu6akY,944
4
+ cytario_cli/awsconfig.py,sha256=tMhT8-1sD-WM8vwiyjnx9foWYW68M9dLNdiv43uNRu0,2933
5
+ cytario_cli/cli.py,sha256=-LXrHTWNs_Pi-BMi80X1wtFV7ErNp2PuZHZoPB8g-qA,9263
6
+ cytario_cli/config.py,sha256=ZymFCMFCovupyi9KkyyPgL-xtHOC1Q1WK-O2EMo45wc,2593
7
+ cytario_cli/oidc.py,sha256=4PIGl6XWwP43ZCVFmFAReZyBJVbjQLXEvzNmEwiP0lY,7261
8
+ cytario_cli-1.0.0.dist-info/METADATA,sha256=qnKlII6cluF0m70p7EALhAA0pxh0uCatAFksQkGv-Gw,3780
9
+ cytario_cli-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ cytario_cli-1.0.0.dist-info/entry_points.txt,sha256=qRPbU8ud7vcQDxs4BaW8GUTdcZfTdrk37e2C8c2-b9M,48
11
+ cytario_cli-1.0.0.dist-info/licenses/LICENSE,sha256=pYtRR8DCj_kycIyXmfJu2o4vbOEVkPt0ngzH4dpgw7s,1064
12
+ cytario_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cytario = cytario_cli.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cytario
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.