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.
- cytario_cli/__init__.py +7 -0
- cytario_cli/__main__.py +8 -0
- cytario_cli/api.py +26 -0
- cytario_cli/awsconfig.py +86 -0
- cytario_cli/cli.py +257 -0
- cytario_cli/config.py +75 -0
- cytario_cli/oidc.py +214 -0
- cytario_cli-1.0.0.dist-info/METADATA +99 -0
- cytario_cli-1.0.0.dist-info/RECORD +12 -0
- cytario_cli-1.0.0.dist-info/WHEEL +4 -0
- cytario_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cytario_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
cytario_cli/__init__.py
ADDED
cytario_cli/__main__.py
ADDED
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", [])]
|
cytario_cli/awsconfig.py
ADDED
|
@@ -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,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.
|