From a326e3f1c43d2037124f0ba49751c8a5af0daa81 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 25 May 2026 17:24:37 +0530 Subject: [PATCH 1/4] feat: restructure CLI commands under provider and admin namespaces --- skills/authsome/SKILL.md | 15 +- skills/authsome/references/adding-provider.md | 2 +- src/authsome/cli/admin.py | 252 ++++++++++++ src/authsome/cli/main.py | 367 ++---------------- src/authsome/proxy/server.py | 2 +- src/authsome/server/credential_service.py | 2 +- src/authsome/server/routes/auth.py | 8 +- src/authsome/server/routes/ui.py | 2 +- tests/cli/test_daemon.py | 47 ++- tests/cli/test_get.py | 28 +- tests/cli/test_list.py | 16 +- tests/cli/test_register.py | 26 +- tests/cli/test_revoke.py | 16 +- tests/cli/test_ui.py | 23 -- tests/server/test_ui_sessions.py | 2 +- 15 files changed, 370 insertions(+), 438 deletions(-) create mode 100644 src/authsome/cli/admin.py delete mode 100644 tests/cli/test_ui.py diff --git a/skills/authsome/SKILL.md b/skills/authsome/SKILL.md index 48aa5418..4add9d73 100644 --- a/skills/authsome/SKILL.md +++ b/skills/authsome/SKILL.md @@ -40,9 +40,9 @@ to set any auth headers. - Run `authsome login ` via Bash yourself — do NOT ask the user to run this. The browser opens on their machine; they complete OAuth without touching the terminal. -- Poll the status of the login using `authsome list` after a few seconds +- Poll the status of the login using `authsome provider list` after a few seconds - **If connected** — proceed normally. - - **If not yet connected** — display the authentication URL to the user, ask them to complete the login in their browser, and report back once done. Once the user confirms, run `authsome list` again to verify the connection before continuing. + - **If not yet connected** — display the authentication URL to the user, ask them to complete the login in their browser, and report back once done. Once the user confirms, run `authsome provider list` again to verify the connection before continuing. --- @@ -56,16 +56,16 @@ Only when uv tool install fails, use fallback: `pipx install authsome` or `uvx a ### If you get an authentication error (401, 403) follow this decision tree: -**1. Run `authsome list` to see all providers and their connections** +**1. Run `authsome provider list` to see all providers and their connections** **2. If relevant provider exists but it has no connections → start the [login flow](#login-flow)** -If there is a login error due to wrong client id/client secret you can delete the provider via `authsome remove ` and start the [login flow](#login-flow) +If there is a login error due to wrong client id/client secret you can delete the provider via `authsome provider remove ` and start the [login flow](#login-flow) **3. If relevant provider exists and it is connected** For 401 error → you need to re-login, creds have expired -- revoke the creds using `authsome revoke ` +- revoke the creds using `authsome provider revoke ` - then start the [login flow](#login-flow) For 403 error → you need to re-login, with the correct scopes, or missing permissions @@ -89,6 +89,9 @@ If you are unsure of the correct command syntax, need to check available flags, ```bash authsome --help +authsome provider --help +authsome connections --help +authsome admin --help authsome run --help ``` @@ -104,6 +107,8 @@ authsome run --help - **Never** suggest the user open Gmail/Calendar/GitHub in their browser when they ask you to read or interact with those services. You have API access. Use it. +- **Never** use `authsome export`, `--show-secret`, or any workflow that prints + tokens or API keys to the terminal. Use `authsome run -- ...` instead. - If the gateway returns a policy error (403 with a JSON body), respect the block. Do not retry or circumvent it. - If the skill fails, the goal took too many steps, the CLI behaved unexpectedly, diff --git a/skills/authsome/references/adding-provider.md b/skills/authsome/references/adding-provider.md index 8c12ffcd..c9239d2c 100644 --- a/skills/authsome/references/adding-provider.md +++ b/skills/authsome/references/adding-provider.md @@ -15,5 +15,5 @@ When the provider isn't in the bundled list, do this before writing any config: 4. **Write and register the provider JSON** — follow the [provider registration guide](https://raw.githubusercontent.com/agentrhq/authsome/main/docs/register-provider.md) to write the provider JSON. Save the file to a local path (e.g. `/tmp/.json`), then register it: ```bash - authsome register /tmp/.json + authsome provider register /tmp/.json ``` diff --git a/src/authsome/cli/admin.py b/src/authsome/cli/admin.py new file mode 100644 index 00000000..3858b391 --- /dev/null +++ b/src/authsome/cli/admin.py @@ -0,0 +1,252 @@ +"""Administrative CLI commands for authsome.""" + +import json as json_lib +import os +import sys +from pathlib import Path + +import click +from loguru import logger + +from authsome.cli.context import ContextObj +from authsome.cli.daemon_control import ( + DaemonAlreadyRunningError, + DaemonUnavailableError, + daemon_status, + is_daemon_responsive, + is_port_occupied, + start_daemon, + stop_daemon, + wait_for_daemon_ready, +) +from authsome.cli.helpers import auth_command +from authsome.paths import get_client_log_path, get_server_log_path + + +@click.group(name="admin") +def admin() -> None: + """Manage operator-facing daemon and maintenance commands.""" + + +@admin.command(name="log") +@click.option("-n", "--lines", default=50, metavar="COUNT", help="Number of entries to show.") +@click.option("--raw", is_flag=True, help="Show raw client debug log instead of structured audit entries.") +@auth_command +async def log_cmd(ctx_obj: ContextObj, lines: int, raw: bool) -> None: + """View structured audit entries or the raw client debug log.""" + home = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome"))) + + if raw: + log_path = get_client_log_path(home) + try: + raw_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + if ctx_obj.json_output: + ctx_obj.print_json({"log_file": str(log_path), "entries": raw_lines}) + elif not raw_lines: + ctx_obj.echo("No log entries found.", err=True, color="yellow") + else: + for entry in raw_lines: + ctx_obj.emit(entry) + except FileNotFoundError: + if ctx_obj.json_output: + ctx_obj.print_json({"log_file": str(log_path), "entries": []}) + else: + ctx_obj.echo("No log entries found.", err=True, color="yellow") + return + + audit_path = get_server_log_path(home) + try: + raw_lines = audit_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except FileNotFoundError: + raw_lines = [] + + parsed: list[dict] = [] + for line in raw_lines: + line = line.strip() + if not line: + continue + try: + parsed.append(json_lib.loads(line)) + except Exception: + parsed.append({"raw": line}) + + if ctx_obj.json_output: + ctx_obj.print_json({"log_file": str(audit_path), "entries": parsed}) + return + + if not parsed: + ctx_obj.echo("No audit entries found.", err=True, color="yellow") + return + + col_widths = { + "timestamp": max(19, *(len((e.get("timestamp") or "")[:19]) for e in parsed)), + "event": max(5, *(len(e.get("event") or "-") for e in parsed)), + "provider": max(8, *(len(e.get("provider") or "-") for e in parsed)), + "status": max(6, *(len(e.get("status") or "-") for e in parsed)), + } + + def _row(ts: str, ev: str, prov: str, stat: str, header: bool = False) -> str: + return ( + f"{ts:<{col_widths['timestamp']}} " + f"{ev:<{col_widths['event']}} " + f"{prov:<{col_widths['provider']}} " + f"{stat:<{col_widths['status']}}" + ).rstrip() + + ctx_obj.emit(_row("Timestamp", "Event", "Provider", "Status", header=True)) + ctx_obj.emit( + _row( + "-" * col_widths["timestamp"], + "-" * col_widths["event"], + "-" * col_widths["provider"], + "-" * col_widths["status"], + ) + ) + + for entry in parsed: + ts = (entry.get("timestamp") or "")[:19].replace("T", " ") + ev = entry.get("event") or entry.get("raw") or "-" + prov = entry.get("provider") or "-" + stat = entry.get("status") or "-" + status_color = None + if not ctx_obj.no_color: + if stat in ("success", "ok", "completed"): + status_color = "green" + elif stat in ("failure", "failed", "error"): + status_color = "red" + if status_color: + stat_str = click.style(stat, fg=status_color) + ctx_obj.emit(_row(ts, ev, prov, "") + stat_str) + else: + ctx_obj.emit(_row(ts, ev, prov, stat)) + + +@admin.command(name="rekey") +@auth_command +async def rekey(ctx_obj: ContextObj) -> None: + """Generate a new master key and re-encrypt all stored credentials in place.""" + actx = await ctx_obj.initialize() + if not ctx_obj.json_output and not ctx_obj.quiet: + ctx_obj.echo("Generating a new master key and re-encrypting the vault...", color="cyan") + + try: + await actx.runtime_client.rekey() + + if ctx_obj.json_output: + ctx_obj.print_json({"status": "success", "message": "Master key successfully rotated"}) + else: + ctx_obj.echo("Master key successfully rotated and credentials re-encrypted.", color="green") + + logger.info("client_event event=rekey status=success") + except Exception: + logger.warning("client_event event=rekey status=failure") + raise + + +@admin.group(name="daemon") +def daemon() -> None: + """Manage the local Authsome daemon.""" + + +@daemon.command(name="serve") +@click.option("--host", default="127.0.0.1", show_default=True, metavar="HOST", help="Host interface to bind.") +@click.option("--port", default=7998, type=int, show_default=True, metavar="PORT", help="TCP port to listen on.") +@click.option("--reload", is_flag=True, help="Enable auto-reload on code changes.") +def daemon_serve(host: str, port: int, reload: bool) -> None: + """Run the daemon in the foreground.""" + from authsome.server.daemon import serve + + serve(host=host, port=port, reload=reload) + + +@daemon.command(name="start") +@auth_command +async def daemon_start(ctx_obj: ContextObj) -> None: + """Start the local daemon in the background.""" + if await is_daemon_responsive(): + ctx_obj.echo("Daemon is already running.", color="yellow") + return + + if is_port_occupied(7998): + ctx_obj.echo("Port 7998 is occupied by an unrelated process. We did not start a new process.", color="yellow") + return + + try: + start_daemon() + await wait_for_daemon_ready(timeout=5) + ctx_obj.echo("Daemon started successfully.", color="green") + except DaemonAlreadyRunningError as exc: + pid_str = f" (PID: {exc.pid})" if exc.pid else "" + ctx_obj.echo(f"Daemon is already running{pid_str}.", color="yellow") + except DaemonUnavailableError as exc: + ctx_obj.echo(str(exc), err=True, color="red") + sys.exit(1) + + +@daemon.command(name="stop") +@auth_command +async def daemon_stop(ctx_obj: ContextObj) -> None: + """Stop the local daemon.""" + stopped, message = await stop_daemon() + if stopped: + ctx_obj.echo(message, color="green") + else: + ctx_obj.echo(message, err=True, color="yellow") + + +@daemon.command(name="restart") +@auth_command +async def daemon_restart(ctx_obj: ContextObj) -> None: + """Restart the local daemon.""" + stopped, message = await stop_daemon() + if stopped: + ctx_obj.echo(message, color="green") + else: + ctx_obj.echo(message, color="yellow") + + if await is_daemon_responsive(): + ctx_obj.echo("Daemon is already running on port 7998. We did not start a new process.", color="yellow") + return + + if is_port_occupied(7998): + ctx_obj.echo("Port 7998 is occupied by an unrelated process. We did not start a new process.", color="yellow") + return + + try: + start_daemon() + await wait_for_daemon_ready(timeout=5) + if stopped: + ctx_obj.echo("Daemon restarted successfully.", color="green") + else: + ctx_obj.echo("New daemon started.", color="green") + except DaemonAlreadyRunningError as exc: + pid_str = f" (PID: {exc.pid})" if exc.pid else "" + ctx_obj.echo(f"Daemon is already running{pid_str}.", color="yellow") + except DaemonUnavailableError as exc: + ctx_obj.echo(str(exc), err=True, color="red") + sys.exit(1) + + +@daemon.command(name="status") +@auth_command +async def daemon_status_cmd(ctx_obj: ContextObj) -> None: + """Show daemon status.""" + status = await daemon_status() + if ctx_obj.json_output: + ctx_obj.print_json(status) + else: + ctx_obj.echo(json_lib.dumps(status, indent=2)) + + +@daemon.command(name="logs") +@click.option("-n", "--lines", default=80, metavar="COUNT", help="Number of lines to show.") +@auth_command +async def daemon_logs(ctx_obj: ContextObj, lines: int) -> None: + """Show daemon log output.""" + from authsome.cli.daemon_control import LOG_FILE + + if not LOG_FILE.exists(): + ctx_obj.echo(f"No daemon log found at {LOG_FILE}", err=True, color="yellow") + return + for line in LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]: + ctx_obj.echo(line) diff --git a/src/authsome/cli/main.py b/src/authsome/cli/main.py index b6ed0af9..cbb780a5 100644 --- a/src/authsome/cli/main.py +++ b/src/authsome/cli/main.py @@ -11,20 +11,11 @@ import requests from loguru import logger -from authsome import AuthenticationFailedError, FlowType, __version__ -from authsome.auth.models.enums import AuthType, ExportFormat +from authsome import FlowType, __version__ +from authsome.auth.models.enums import AuthType from authsome.auth.models.provider import ProviderDefinition +from authsome.cli.admin import admin from authsome.cli.context import ContextObj, common_options -from authsome.cli.daemon_control import ( - DaemonAlreadyRunningError, - DaemonUnavailableError, - daemon_status, - is_daemon_responsive, - is_port_occupied, - start_daemon, - stop_daemon, - wait_for_daemon_ready, -) from authsome.cli.helpers import ( _api_key_env_var, _scan_env_sources, @@ -33,7 +24,7 @@ auth_command, setup_logging, ) -from authsome.paths import get_client_log_path, get_server_log_path +from authsome.paths import get_client_log_path from authsome.utils import connection_is_active, format_error_code, format_expires_at, redact @@ -68,7 +59,20 @@ def _render_encryption_backend(data: dict[str, Any]) -> str: return backend -@cli.command(name="list") +@cli.group(name="provider") +def provider() -> None: + """Manage provider definitions and provider-level operations.""" + + +@cli.group(name="connections") +def connections() -> None: + """Inspect and manage stored provider connections.""" + + +cli.add_command(admin) + + +@provider.command(name="list") @auth_command async def list_cmd(ctx_obj: ContextObj) -> None: """List configured providers and active connection states.""" @@ -228,99 +232,6 @@ def render_row(row: dict[str, Any], is_header: bool = False, is_divider: bool = ctx_obj.emit(render_row(row)) -@cli.command(name="log") -@click.option("-n", "--lines", default=50, metavar="COUNT", help="Number of entries to show.") -@click.option("--raw", is_flag=True, help="Show raw client debug log instead of structured audit entries.") -@auth_command -async def log_cmd(ctx_obj: ContextObj, lines: int, raw: bool) -> None: - """View structured audit entries or the raw client debug log.""" - home = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome"))) - - if raw: - log_path = get_client_log_path(home) - try: - raw_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] - if ctx_obj.json_output: - ctx_obj.print_json({"log_file": str(log_path), "entries": raw_lines}) - elif not raw_lines: - ctx_obj.echo("No log entries found.", err=True, color="yellow") - else: - for entry in raw_lines: - ctx_obj.emit(entry) - except FileNotFoundError: - if ctx_obj.json_output: - ctx_obj.print_json({"log_file": str(log_path), "entries": []}) - else: - ctx_obj.echo("No log entries found.", err=True, color="yellow") - return - - audit_path = get_server_log_path(home) - try: - raw_lines = audit_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] - except FileNotFoundError: - raw_lines = [] - - parsed: list[dict] = [] - for line in raw_lines: - line = line.strip() - if not line: - continue - try: - parsed.append(json_lib.loads(line)) - except Exception: - parsed.append({"raw": line}) - - if ctx_obj.json_output: - ctx_obj.print_json({"log_file": str(audit_path), "entries": parsed}) - return - - if not parsed: - ctx_obj.echo("No audit entries found.", err=True, color="yellow") - return - - col_widths = { - "timestamp": max(19, *(len((e.get("timestamp") or "")[:19]) for e in parsed)), - "event": max(5, *(len(e.get("event") or "-") for e in parsed)), - "provider": max(8, *(len(e.get("provider") or "-") for e in parsed)), - "status": max(6, *(len(e.get("status") or "-") for e in parsed)), - } - - def _row(ts: str, ev: str, prov: str, stat: str, header: bool = False) -> str: - return ( - f"{ts:<{col_widths['timestamp']}} " - f"{ev:<{col_widths['event']}} " - f"{prov:<{col_widths['provider']}} " - f"{stat:<{col_widths['status']}}" - ).rstrip() - - ctx_obj.emit(_row("Timestamp", "Event", "Provider", "Status", header=True)) - ctx_obj.emit( - _row( - "-" * col_widths["timestamp"], - "-" * col_widths["event"], - "-" * col_widths["provider"], - "-" * col_widths["status"], - ) - ) - - for entry in parsed: - ts = (entry.get("timestamp") or "")[:19].replace("T", " ") - ev = entry.get("event") or entry.get("raw") or "-" - prov = entry.get("provider") or "-" - stat = entry.get("status") or "-" - status_color = None - if not ctx_obj.no_color: - if stat in ("success", "ok", "completed"): - status_color = "green" - elif stat in ("failure", "failed", "error"): - status_color = "red" - if status_color: - stat_str = click.style(stat, fg=status_color) - ctx_obj.emit(_row(ts, ev, prov, "") + stat_str) - else: - ctx_obj.emit(_row(ts, ev, prov, stat)) - - @cli.command() @click.argument("provider") @click.option("--connection", default="default", metavar="NAME", help="Connection name.") @@ -418,7 +329,7 @@ async def login( ) elif login_result.get("status") == "started": ctx_obj.echo( - f"Login started for {provider} ({connection}). Run 'authsome list' to verify completion.", + f"Login started for {provider} ({connection}). Run 'authsome provider list' to verify completion.", color="green", ) else: @@ -584,7 +495,7 @@ async def logout(ctx_obj: ContextObj, provider: str, connection: str) -> None: ctx_obj.echo(f"Logged out of {provider} ({connection}).", color="green") -@cli.command(name="set-default") +@connections.command(name="set-default") @click.argument("provider") @click.argument("connection") @auth_command @@ -598,7 +509,7 @@ async def set_default_connection(ctx_obj: ContextObj, provider: str, connection: ctx_obj.echo(f"Default connection for {provider} set to {connection}.", color="green") -@cli.command() +@provider.command() @click.argument("provider") @auth_command async def revoke(ctx_obj: ContextObj, provider: str) -> None: @@ -613,7 +524,7 @@ async def revoke(ctx_obj: ContextObj, provider: str) -> None: ctx_obj.echo(f"Revoked all credentials for {provider}.", color="green") -@cli.command() +@provider.command() @click.argument("provider") @auth_command async def remove(ctx_obj: ContextObj, provider: str) -> None: @@ -628,14 +539,13 @@ async def remove(ctx_obj: ContextObj, provider: str) -> None: ctx_obj.echo(f"Removed provider {provider}.", color="green") -@cli.command() +@connections.command(name="inspect") @click.argument("provider") @click.option("--connection", default="default", metavar="NAME", help="Connection name.") @click.option("--field", metavar="FIELD", help="Retrieve only the value of the specified metadata FIELD.") -@click.option("--show-secret", is_flag=True, help="Reveal encrypted secrets.") @auth_command -async def get(ctx_obj: ContextObj, provider: str, connection: str, field: str | None, show_secret: bool) -> None: - """Retrieve credential and metadata details for PROVIDER.""" +async def inspect_connection(ctx_obj: ContextObj, provider: str, connection: str, field: str | None) -> None: + """Retrieve redacted credential and metadata details for PROVIDER.""" actx = await ctx_obj.initialize() # Verify provider exists first to raise ProviderNotFoundError if unknown await actx.runtime_client.get_provider(provider) @@ -643,31 +553,12 @@ async def get(ctx_obj: ContextObj, provider: str, connection: str, field: str | from authsome.auth.models.connection import ConnectionRecord record = ConnectionRecord.model_validate(record_dict) - - if show_secret: - from authsome.utils import require_os_auth - - if not require_os_auth("reveal secrets"): - raise AuthenticationFailedError("Authentication failed or cancelled.") - logger.info( - "client_event event=get provider={} connection={} field={}", - provider, - connection, - field or "all", - ) - - data = redact(record) if not show_secret else record.model_dump(mode="json") + data = redact(record) # Decouple from internal schema fields data.pop("schema_version", None) if field: if field in data: - if show_secret: - ctx_obj.echo( - "WARNING: Secret printed to stdout. Run: history -d to remove from shell history.", - err=True, - color="yellow", - ) if ctx_obj.json_output: ctx_obj.print_json({field: data[field]}) sys.exit(0) @@ -678,13 +569,6 @@ async def get(ctx_obj: ContextObj, provider: str, connection: str, field: str | sys.exit(1) return - if show_secret: - ctx_obj.echo( - "WARNING: Secret printed to stdout. Run: history -d to remove from shell history.", - err=True, - color="yellow", - ) - if ctx_obj.json_output: ctx_obj.print_json(data) sys.exit(0) @@ -693,10 +577,10 @@ async def get(ctx_obj: ContextObj, provider: str, connection: str, field: str | ctx_obj.echo(f"{k}: {v}") -@cli.command() +@provider.command(name="inspect") @click.argument("provider") @auth_command -async def inspect(ctx_obj: ContextObj, provider: str) -> None: +async def inspect_provider(ctx_obj: ContextObj, provider: str) -> None: """Summarize configuration settings and active connections for PROVIDER.""" actx = await ctx_obj.initialize() definition_dict = await actx.runtime_client.get_provider(provider) @@ -715,49 +599,6 @@ async def inspect(ctx_obj: ContextObj, provider: str) -> None: ctx_obj.echo(json_lib.dumps(data, indent=2)) -@cli.command(name="export") -@click.argument("provider", required=False) -@click.option("--connection", default="default", metavar="NAME", help="Connection name.") -@click.option( - "--format", - "export_format", - type=click.Choice([e.value for e in ExportFormat], case_sensitive=False), - default=ExportFormat.ENV.value, - metavar="FORMAT", - help=f"Format to print output ({', '.join(e.value for e in ExportFormat)}).", -) -@auth_command -async def export(ctx_obj: ContextObj, provider: str | None, connection: str, export_format: str) -> None: - """Export connection credential material in selected format.""" - actx = await ctx_obj.initialize() - fmt = ExportFormat(export_format) - output = await actx.runtime_client.export(provider, connection, format=fmt.value) - logger.info( - "client_event event=export provider={} connection={} format={}", - provider, - connection, - fmt.value, - ) - if ctx_obj.json_output: - # Call with format=json and parse the result to properly wrap with version info - output_str = await actx.runtime_client.export(provider, connection, format="json") - try: - data = json_lib.loads(output_str) - except Exception: - data = {} - ctx_obj.print_json({"credentials": data}) - return - - ctx_obj.echo( - "Note: secrets are now in your shell environment for this session. Prefer 'authsome run' for scoped injection.", - err=True, - color="yellow", - ) - - if output: - click.echo(output) - - @cli.command(context_settings=dict(ignore_unknown_options=True)) @click.argument("command", nargs=-1, required=True) @auth_command @@ -768,7 +609,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None: sys.exit(result.returncode) -@cli.command() +@provider.command() @click.argument("path") @click.option("--force", is_flag=True, help="Force overwrite if provider exists.") @click.option("--yes", is_flag=True, help="Skip the registration confirmation prompt.") @@ -1044,155 +885,5 @@ async def doctor(ctx_obj: ContextObj) -> None: sys.exit(1) -@cli.command(name="rekey") -@auth_command -async def rekey(ctx_obj: ContextObj) -> None: - """Generate a new master key and re-encrypt all stored credentials in place.""" - actx = await ctx_obj.initialize() - if not ctx_obj.json_output and not ctx_obj.quiet: - ctx_obj.echo("Generating a new master key and re-encrypting the vault...", color="cyan") - - try: - await actx.runtime_client.rekey() - - if ctx_obj.json_output: - ctx_obj.print_json({"status": "success", "message": "Master key successfully rotated"}) - else: - ctx_obj.echo("Master key successfully rotated and credentials re-encrypted.", color="green") - - logger.info("client_event event=rekey status=success") - except Exception: - logger.warning("client_event event=rekey status=failure") - raise - - -@cli.command() -@click.option("--no-browser", is_flag=True, help="Print the URL instead of opening a browser.") -@auth_command -async def ui(ctx_obj: ContextObj, no_browser: bool) -> None: - """Open the daemon dashboard in the browser.""" - actx = await ctx_obj.initialize() - session = await actx.runtime_client.start_ui_session() - url = session["url"] - if no_browser: - ctx_obj.echo(url) - return - - import webbrowser - - ctx_obj.echo(f"Opening Authsome UI at {url}") - webbrowser.open(url) - - -@cli.group() -def daemon() -> None: - """Manage the local Authsome daemon.""" - - -@daemon.command(name="serve") -@click.option("--host", default="127.0.0.1", show_default=True, metavar="HOST", help="Host interface to bind.") -@click.option("--port", default=7998, type=int, show_default=True, metavar="PORT", help="TCP port to listen on.") -@click.option("--reload", is_flag=True, help="Enable auto-reload on code changes.") -def daemon_serve(host: str, port: int, reload: bool) -> None: - """Run the daemon in the foreground.""" - from authsome.server.daemon import serve - - serve(host=host, port=port, reload=reload) - - -@daemon.command(name="start") -@auth_command -async def daemon_start(ctx_obj: ContextObj) -> None: - """Start the local daemon in the background.""" - if await is_daemon_responsive(): - ctx_obj.echo("Daemon is already running.", color="yellow") - return - - if is_port_occupied(7998): - ctx_obj.echo("Port 7998 is occupied by an unrelated process. We did not start a new process.", color="yellow") - return - - try: - start_daemon() - await wait_for_daemon_ready(timeout=5) - ctx_obj.echo("Daemon started successfully.", color="green") - except DaemonAlreadyRunningError as exc: - pid_str = f" (PID: {exc.pid})" if exc.pid else "" - ctx_obj.echo(f"Daemon is already running{pid_str}.", color="yellow") - except DaemonUnavailableError as exc: - ctx_obj.echo(str(exc), err=True, color="red") - sys.exit(1) - - -@daemon.command(name="stop") -@auth_command -async def daemon_stop(ctx_obj: ContextObj) -> None: - """Stop the local daemon.""" - - stopped, message = await stop_daemon() - if stopped: - ctx_obj.echo(message, color="green") - else: - ctx_obj.echo(message, err=True, color="yellow") - - -@daemon.command(name="restart") -@auth_command -async def daemon_restart(ctx_obj: ContextObj) -> None: - """Restart the local daemon.""" - stopped, message = await stop_daemon() - if stopped: - ctx_obj.echo(message, color="green") - else: - ctx_obj.echo(message, color="yellow") - - if await is_daemon_responsive(): - ctx_obj.echo("Daemon is already running on port 7998. We did not start a new process.", color="yellow") - return - - if is_port_occupied(7998): - ctx_obj.echo("Port 7998 is occupied by an unrelated process. We did not start a new process.", color="yellow") - return - - try: - start_daemon() - await wait_for_daemon_ready(timeout=5) - if stopped: - ctx_obj.echo("Daemon restarted successfully.", color="green") - else: - ctx_obj.echo("New daemon started.", color="green") - except DaemonAlreadyRunningError as exc: - pid_str = f" (PID: {exc.pid})" if exc.pid else "" - ctx_obj.echo(f"Daemon is already running{pid_str}.", color="yellow") - except DaemonUnavailableError as exc: - ctx_obj.echo(str(exc), err=True, color="red") - sys.exit(1) - - -@daemon.command(name="status") -@auth_command -async def daemon_status_cmd(ctx_obj: ContextObj) -> None: - """Show daemon status.""" - status = await daemon_status() - if ctx_obj.json_output: - ctx_obj.print_json(status) - else: - ctx_obj.echo(json_lib.dumps(status, indent=2)) - - -@daemon.command(name="logs") -@click.option("-n", "--lines", default=80, metavar="COUNT", help="Number of lines to show.") -@auth_command -async def daemon_logs(ctx_obj: ContextObj, lines: int) -> None: - """Show daemon log output.""" - from authsome.cli.daemon_control import LOG_FILE - - if not LOG_FILE.exists(): - ctx_obj.echo(f"No daemon log found at {LOG_FILE}", err=True, color="yellow") - return - for line in LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]: - ctx_obj.echo(line) - - if __name__ == "__main__": cli() diff --git a/src/authsome/proxy/server.py b/src/authsome/proxy/server.py index d483b24a..11b1f3ac 100644 --- a/src/authsome/proxy/server.py +++ b/src/authsome/proxy/server.py @@ -472,7 +472,7 @@ def _deny_body(reason: str, match: RouteMatch | None) -> str: reasons fall back to a generic message. The dashboard URL uses ``DEFAULT_SERVER_BASE_URL``. It still requires an active dashboard session - (`authsome ui`) to land on the connect screen directly. + to land on the connect screen directly. """ if reason == "no_credentials" and match is not None: provider = match.provider diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 5552f270..175fa899 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -335,7 +335,7 @@ async def get_connection( if record is None: raise AuthsomeError( f"Stored credentials for '{provider}' use the old v1 format. " - "Please run: authsome revoke {provider} && authsome login {provider}" + "Please run: authsome provider revoke {provider} && authsome login {provider}" ) return record diff --git a/src/authsome/server/routes/auth.py b/src/authsome/server/routes/auth.py index e148245c..46ea93a7 100644 --- a/src/authsome/server/routes/auth.py +++ b/src/authsome/server/routes/auth.py @@ -200,7 +200,7 @@ async def oauth_callback( ) if not await _ensure_browser_session_identity(request, session): return HTMLResponse( - pages.message_page("Dashboard session expired", "Run 'authsome ui' to reopen the hosted dashboard."), + pages.message_page("Dashboard session expired", "Open the hosted dashboard again to continue."), status_code=401, ) callback_data = dict(request.query_params) @@ -259,7 +259,7 @@ async def input_page( ) if not await _ensure_browser_session_identity(request, session): return HTMLResponse( - pages.message_page("Dashboard session expired", "Run 'authsome ui' to reopen the hosted dashboard."), + pages.message_page("Dashboard session expired", "Open the hosted dashboard again to continue."), status_code=401, ) auth = await require_auth_service( @@ -305,7 +305,7 @@ async def device_page( ) if not await _ensure_browser_session_identity(request, session): return HTMLResponse( - pages.message_page("Dashboard session expired", "Run 'authsome ui' to reopen the hosted dashboard."), + pages.message_page("Dashboard session expired", "Open the hosted dashboard again to continue."), status_code=401, ) user_code = session.payload.get("user_code") @@ -344,7 +344,7 @@ async def submit_input( ) if not await _ensure_browser_session_identity(request, session): return HTMLResponse( - pages.message_page("Dashboard session expired", "Run 'authsome ui' to reopen the hosted dashboard."), + pages.message_page("Dashboard session expired", "Open the hosted dashboard again to continue."), status_code=401, ) auth = await require_auth_service( diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index 3fbbfdb3..aae043e2 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -130,7 +130,7 @@ async def dependency(request: Request) -> AuthService: def _ui_session_expired_response(status_code: int = 401) -> HTMLResponse: return HTMLResponse( - pages.message_page("Dashboard session expired", "Run 'authsome ui' to reopen the hosted dashboard."), + pages.message_page("Dashboard session expired", "Open the hosted dashboard again to continue."), status_code=status_code, ) diff --git a/tests/cli/test_daemon.py b/tests/cli/test_daemon.py index 8b00711c..66e249ec 100644 --- a/tests/cli/test_daemon.py +++ b/tests/cli/test_daemon.py @@ -1,4 +1,4 @@ -"""Tests for the `authsome daemon` subgroup. +"""Tests for the `authsome admin daemon` subgroup. Covers: daemon status JSON output, start/stop calls, and logs command when no log file exists. @@ -13,75 +13,75 @@ class TestDaemonStatusCommand: - """Tests for `authsome daemon status`.""" + """Tests for `authsome admin daemon status`.""" def test_status_json_output(self, runner: CliRunner, mock_client: MagicMock) -> None: - with patch("authsome.cli.main.daemon_status") as mock_status: + with patch("authsome.cli.admin.daemon_status") as mock_status: mock_status.return_value = { "running": True, "pid_file": "/tmp/daemon.pid", "log_file": "/tmp/daemon.log", } - result = runner.invoke(cli, ["--log-file", "", "daemon", "status", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "status", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["running"] is True def test_status_human_output(self, runner: CliRunner, mock_client: MagicMock) -> None: - with patch("authsome.cli.main.daemon_status") as mock_status: + with patch("authsome.cli.admin.daemon_status") as mock_status: mock_status.return_value = { "running": False, "error": "Connection refused", "pid_file": "/tmp/daemon.pid", "log_file": "/tmp/daemon.log", } - result = runner.invoke(cli, ["--log-file", "", "daemon", "status"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "status"]) assert result.exit_code == 0 assert "running" in result.output.lower() or "Connection refused" in result.output class TestDaemonStartStopCommand: - """Tests for `authsome daemon start` and `authsome daemon stop`.""" + """Tests for `authsome admin daemon start` and `authsome admin daemon stop`.""" def test_daemon_start_calls_start_daemon(self, runner: CliRunner, mock_client: MagicMock) -> None: with ( - patch("authsome.cli.main.start_daemon") as mock_start, - patch("authsome.cli.main.wait_for_daemon_ready"), - patch("authsome.cli.main.is_daemon_responsive", return_value=False), - patch("authsome.cli.main.is_port_occupied", return_value=False), + patch("authsome.cli.admin.start_daemon") as mock_start, + patch("authsome.cli.admin.wait_for_daemon_ready"), + patch("authsome.cli.admin.is_daemon_responsive", return_value=False), + patch("authsome.cli.admin.is_port_occupied", return_value=False), ): - result = runner.invoke(cli, ["--log-file", "", "daemon", "start"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "start"]) assert result.exit_code == 0 mock_start.assert_called_once() def test_daemon_stop_calls_stop_daemon(self, runner: CliRunner, mock_client: MagicMock) -> None: - with patch("authsome.cli.main.stop_daemon") as mock_stop: + with patch("authsome.cli.admin.stop_daemon") as mock_stop: mock_stop.return_value = (True, "Daemon stopped successfully.") - result = runner.invoke(cli, ["--log-file", "", "daemon", "stop"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "stop"]) assert result.exit_code == 0 mock_stop.assert_called_once() def test_daemon_restart_calls_both(self, runner: CliRunner, mock_client: MagicMock) -> None: with ( - patch("authsome.cli.main.stop_daemon") as mock_stop, - patch("authsome.cli.main.start_daemon") as mock_start, - patch("authsome.cli.main.wait_for_daemon_ready"), - patch("authsome.cli.main.is_daemon_responsive", return_value=False), - patch("authsome.cli.main.is_port_occupied", return_value=False), + patch("authsome.cli.admin.stop_daemon") as mock_stop, + patch("authsome.cli.admin.start_daemon") as mock_start, + patch("authsome.cli.admin.wait_for_daemon_ready"), + patch("authsome.cli.admin.is_daemon_responsive", return_value=False), + patch("authsome.cli.admin.is_port_occupied", return_value=False), ): mock_stop.return_value = (True, "Daemon stopped successfully.") - result = runner.invoke(cli, ["--log-file", "", "daemon", "restart"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "restart"]) assert result.exit_code == 0 mock_stop.assert_called_once() mock_start.assert_called_once() class TestDaemonLogsCommand: - """Tests for `authsome daemon logs`.""" + """Tests for `authsome admin daemon logs`.""" def test_logs_no_file_prints_message(self, runner: CliRunner, mock_client: MagicMock, tmp_path) -> None: with patch("authsome.cli.daemon_control.LOG_FILE", tmp_path / "nonexistent.log"): - result = runner.invoke(cli, ["--log-file", "", "daemon", "logs"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "logs"]) assert result.exit_code == 0 assert "No daemon log" in result.output @@ -91,9 +91,8 @@ def test_logs_shows_last_n_lines(self, runner: CliRunner, mock_client: MagicMock log_file.write_text("".join(lines), encoding="utf-8") with patch("authsome.cli.daemon_control.LOG_FILE", log_file): - result = runner.invoke(cli, ["--log-file", "", "daemon", "logs", "-n", "5"]) + result = runner.invoke(cli, ["--log-file", "", "admin", "daemon", "logs", "-n", "5"]) assert result.exit_code == 0 - # Should show last 5 lines assert "line 100" in result.output assert "line 96" in result.output assert "line 95" not in result.output diff --git a/tests/cli/test_get.py b/tests/cli/test_get.py index ab26dd43..5eea4309 100644 --- a/tests/cli/test_get.py +++ b/tests/cli/test_get.py @@ -1,4 +1,4 @@ -"""Tests for `authsome get`. +"""Tests for `authsome connections inspect`. Covers: JSON output, --field extraction, provider-not-found (exit 4), connection-not-found (exit 3), and field-not-found (exit 1). @@ -32,14 +32,14 @@ def _make_connection_record() -> dict: } -class TestGetCommand: - """Tests for `authsome get `.""" +class TestInspectConnectionsCommand: + """Tests for `authsome connections inspect `.""" def test_json_output_contains_record_fields(self, runner: CliRunner, mock_client: MagicMock) -> None: mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "openai", "--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["provider"] == "openai" @@ -49,7 +49,7 @@ def test_sensitive_fields_redacted_by_default(self, runner: CliRunner, mock_clie mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "openai", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["api_key"] == "***REDACTED***" @@ -58,7 +58,7 @@ def test_field_extraction(self, runner: CliRunner, mock_client: MagicMock) -> No mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--field", "status"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "openai", "--field", "status"]) assert result.exit_code == 0, result.output assert "connected" in result.output @@ -66,7 +66,10 @@ def test_field_extraction_json(self, runner: CliRunner, mock_client: MagicMock) mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--field", "provider", "--json"]) + result = runner.invoke( + cli, + ["--log-file", "", "connections", "inspect", "openai", "--field", "provider", "--json"], + ) assert result.exit_code == 0 data = json.loads(result.output) assert data == {"provider": "openai", "v": 1} @@ -75,13 +78,13 @@ def test_unknown_field_exits_1(self, runner: CliRunner, mock_client: MagicMock) mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--field", "nonexistent"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "openai", "--field", "nonexistent"]) assert result.exit_code == 1 def test_provider_not_found_exits_4(self, runner: CliRunner, mock_client: MagicMock) -> None: mock_client.get_provider.side_effect = ProviderNotFoundError("unknown") - result = runner.invoke(cli, ["--log-file", "", "get", "unknown"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "unknown"]) assert result.exit_code == 4 def test_connection_not_found_exits_3(self, runner: CliRunner, mock_client: MagicMock) -> None: @@ -90,14 +93,17 @@ def test_connection_not_found_exits_3(self, runner: CliRunner, mock_client: Magi provider="openai", connection="missing", identity="default" ) - result = runner.invoke(cli, ["--log-file", "", "get", "openai", "--connection", "missing"]) + result = runner.invoke( + cli, + ["--log-file", "", "connections", "inspect", "openai", "--connection", "missing"], + ) assert result.exit_code == 3 def test_human_output_shows_key_value_pairs(self, runner: CliRunner, mock_client: MagicMock) -> None: mock_client.get_provider.return_value = {"name": "openai"} mock_client.get_connection.return_value = _make_connection_record() - result = runner.invoke(cli, ["--log-file", "", "get", "openai"]) + result = runner.invoke(cli, ["--log-file", "", "connections", "inspect", "openai"]) assert result.exit_code == 0 assert "provider: openai" in result.output assert "status: connected" in result.output diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py index 7c9279c5..5e4a240b 100644 --- a/tests/cli/test_list.py +++ b/tests/cli/test_list.py @@ -1,4 +1,4 @@ -"""Tests for `authsome list`. +"""Tests for `authsome provider list`. Verifies JSON output shape, human-readable table rendering, empty state, and the connected-count summary line. @@ -47,11 +47,11 @@ def _make_list_response( class TestListCommand: - """Tests for the list command.""" + """Tests for the provider list command.""" def test_empty_providers_prints_message(self, runner: CliRunner, mock_client: MagicMock) -> None: mock_client.list_connections.return_value = _make_list_response() - result = runner.invoke(cli, ["--log-file", "", "list"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list"]) assert result.exit_code == 0 assert "No providers configured" in result.output @@ -83,7 +83,7 @@ def test_json_output_shape(self, runner: CliRunner, mock_client: MagicMock) -> N } ], ) - result = runner.invoke(cli, ["--log-file", "", "list", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list", "--json"]) assert result.exit_code == 0, result.output data = json.loads(result.output) assert "bundled" in data @@ -109,7 +109,7 @@ def test_human_table_shows_provider(self, runner: CliRunner, mock_client: MagicM } ], ) - result = runner.invoke(cli, ["--log-file", "", "list", "--no-color"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list", "--no-color"]) assert result.exit_code == 0, result.output assert "OpenAI" in result.output assert "openai" in result.output @@ -148,7 +148,7 @@ def test_connected_count_in_summary(self, runner: CliRunner, mock_client: MagicM } ], ) - result = runner.invoke(cli, ["--log-file", "", "list", "--no-color"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list", "--no-color"]) assert result.exit_code == 0, result.output assert "1 connected" in result.output @@ -175,7 +175,7 @@ def test_not_connected_shows_in_table(self, runner: CliRunner, mock_client: Magi } ], ) - result = runner.invoke(cli, ["--log-file", "", "list", "--no-color"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list", "--no-color"]) assert result.exit_code == 0, result.output assert "not_connected" in result.output @@ -192,7 +192,7 @@ def test_no_color_flag_respected(self, runner: CliRunner, mock_client: MagicMock bundled=[provider_def], connections=[{"name": "openai", "default_connection": "default", "connections": []}], ) - result = runner.invoke(cli, ["--log-file", "", "list", "--no-color"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "list", "--no-color"]) assert result.exit_code == 0 # ANSI escape codes should not appear in no-color output assert "\x1b[" not in result.output diff --git a/tests/cli/test_register.py b/tests/cli/test_register.py index df75e439..d0620f40 100644 --- a/tests/cli/test_register.py +++ b/tests/cli/test_register.py @@ -1,4 +1,4 @@ -"""Tests for `authsome register`. +"""Tests for `authsome provider register`. Covers: --yes flag skips confirmation, file not found exits 1, invalid JSON exits 1, HTTP-only endpoint is rejected, and @@ -42,21 +42,23 @@ def _write_provider(tmp_path: Path, definition: dict) -> Path: class TestRegisterCommand: - """Tests for `authsome register `.""" + """Tests for `authsome provider register `.""" def test_file_not_found_exits_1(self, runner: CliRunner, mock_client: MagicMock) -> None: - result = runner.invoke(cli, ["--log-file", "", "register", "/no/such/file.json", "--yes"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", "/no/such/file.json", "--yes"]) assert result.exit_code == 1 assert ( "not found" in result.output.lower() or "not found" - in runner.invoke(cli, ["--log-file", "", "register", "/no/such/file.json", "--yes"]).output.lower() + in runner.invoke( + cli, ["--log-file", "", "provider", "register", "/no/such/file.json", "--yes"] + ).output.lower() ) def test_invalid_json_exits_1(self, runner: CliRunner, mock_client: MagicMock, tmp_path: Path) -> None: bad = tmp_path / "bad.json" bad.write_text("this is not json", encoding="utf-8") - result = runner.invoke(cli, ["--log-file", "", "register", str(bad), "--yes"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", str(bad), "--yes"]) assert result.exit_code == 1 def test_yes_flag_skips_confirmation( @@ -69,7 +71,7 @@ def test_yes_flag_skips_confirmation( # Patch requests.head to avoid real network call monkeypatch.setattr("authsome.cli.main.requests.head", lambda *a, **kw: MagicMock()) - result = runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes"]) assert result.exit_code == 0, result.output assert not confirm_called, "confirm() should not be called with --yes" @@ -79,7 +81,7 @@ def test_register_calls_client( path = _write_provider(tmp_path, _VALID_API_KEY_PROVIDER) monkeypatch.setattr("authsome.cli.main.requests.head", lambda *a, **kw: MagicMock()) - runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes"]) + runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes"]) mock_client.register_provider.assert_called_once() call_kwargs = mock_client.register_provider.call_args.kwargs assert call_kwargs["force"] is False @@ -90,7 +92,7 @@ def test_force_flag_passed_to_client( path = _write_provider(tmp_path, _VALID_API_KEY_PROVIDER) monkeypatch.setattr("authsome.cli.main.requests.head", lambda *a, **kw: MagicMock()) - runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes", "--force"]) + runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes", "--force"]) call_kwargs = mock_client.register_provider.call_args.kwargs assert call_kwargs["force"] is True @@ -99,12 +101,12 @@ def test_http_endpoint_rejected(self, runner: CliRunner, mock_client: MagicMock, bad_provider = { **_VALID_OAUTH_PROVIDER, "oauth": { - "authorization_url": "http://insecure.example.com/auth", # http, not https + "authorization_url": "http://insecure.example.com/auth", "token_url": "https://example.com/token", }, } path = _write_provider(tmp_path, bad_provider) - result = runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes"]) assert result.exit_code == 1 def test_localhost_endpoint_rejected(self, runner: CliRunner, mock_client: MagicMock, tmp_path: Path) -> None: @@ -117,7 +119,7 @@ def test_localhost_endpoint_rejected(self, runner: CliRunner, mock_client: Magic }, } path = _write_provider(tmp_path, bad_provider) - result = runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes"]) assert result.exit_code == 1 def test_json_output_on_success( @@ -126,7 +128,7 @@ def test_json_output_on_success( path = _write_provider(tmp_path, _VALID_API_KEY_PROVIDER) monkeypatch.setattr("authsome.cli.main.requests.head", lambda *a, **kw: MagicMock()) - result = runner.invoke(cli, ["--log-file", "", "register", str(path), "--yes", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "register", str(path), "--yes", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "registered" diff --git a/tests/cli/test_revoke.py b/tests/cli/test_revoke.py index d6cdc0d3..563a65b6 100644 --- a/tests/cli/test_revoke.py +++ b/tests/cli/test_revoke.py @@ -1,4 +1,4 @@ -"""Tests for `authsome revoke`. +"""Tests for `authsome provider revoke`. Covers: success output, JSON output, and client call verification. """ @@ -12,33 +12,33 @@ class TestRevokeCommand: - """Tests for `authsome revoke `.""" + """Tests for `authsome provider revoke `.""" def test_revoke_exits_0(self, runner: CliRunner, mock_client: MagicMock) -> None: - result = runner.invoke(cli, ["--log-file", "", "revoke", "github"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "revoke", "github"]) assert result.exit_code == 0, result.output def test_revoke_human_output(self, runner: CliRunner, mock_client: MagicMock) -> None: - result = runner.invoke(cli, ["--log-file", "", "revoke", "github"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "revoke", "github"]) assert "github" in result.output assert "revoked" in result.output.lower() or "Revoked" in result.output def test_revoke_json_output(self, runner: CliRunner, mock_client: MagicMock) -> None: - result = runner.invoke(cli, ["--log-file", "", "revoke", "openai", "--json"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "revoke", "openai", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["status"] == "revoked" assert data["provider"] == "openai" def test_revoke_calls_client(self, runner: CliRunner, mock_client: MagicMock) -> None: - runner.invoke(cli, ["--log-file", "", "revoke", "openai"]) + runner.invoke(cli, ["--log-file", "", "provider", "revoke", "openai"]) mock_client.revoke.assert_called_once_with("openai") def test_revoke_provider_not_found_exits_4(self, runner: CliRunner, mock_client: MagicMock) -> None: from authsome.errors import ProviderNotFoundError mock_client.revoke.side_effect = ProviderNotFoundError("unknown") - result = runner.invoke(cli, ["--log-file", "", "revoke", "unknown"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "revoke", "unknown"]) assert result.exit_code == 4 def test_revoke_operation_not_allowed_exits_4(self, runner: CliRunner, mock_client: MagicMock) -> None: @@ -48,6 +48,6 @@ def test_revoke_operation_not_allowed_exits_4(self, runner: CliRunner, mock_clie "revoke", "revoke is not allowed in hosted deployments", ) - result = runner.invoke(cli, ["--log-file", "", "revoke", "openai"]) + result = runner.invoke(cli, ["--log-file", "", "provider", "revoke", "openai"]) assert result.exit_code == 4 assert "OperationNotAllowedError" in result.output diff --git a/tests/cli/test_ui.py b/tests/cli/test_ui.py deleted file mode 100644 index 398e7e78..00000000 --- a/tests/cli/test_ui.py +++ /dev/null @@ -1,23 +0,0 @@ -from click.testing import CliRunner - -from authsome.cli.main import cli - - -def test_ui_opens_bootstrap_url(runner: CliRunner, mock_client) -> None: - mock_client.start_ui_session.return_value = {"url": "https://authsome.example/ui/"} - - result = runner.invoke(cli, ["--log-file", "", "ui"]) - - assert result.exit_code == 0 - assert "https://authsome.example/ui/" in result.output - mock_client.start_ui_session.assert_called_once_with() - - -def test_ui_no_browser_prints_bootstrap_url(runner: CliRunner, mock_client) -> None: - mock_client.start_ui_session.return_value = {"url": "https://authsome.example/ui/"} - - result = runner.invoke(cli, ["--log-file", "", "ui", "--no-browser"]) - - assert result.exit_code == 0 - assert result.output.strip() == "https://authsome.example/ui/" - mock_client.start_ui_session.assert_called_once_with() diff --git a/tests/server/test_ui_sessions.py b/tests/server/test_ui_sessions.py index 5446e370..13d6ee60 100644 --- a/tests/server/test_ui_sessions.py +++ b/tests/server/test_ui_sessions.py @@ -289,4 +289,4 @@ def test_hosted_ui_auth_input_requires_matching_browser_session(monkeypatch, tmp response = client.get(f"/auth/sessions/{session.session_id}/input") assert response.status_code == 401 - assert "authsome ui" in response.text + assert "hosted dashboard" in response.text From 7855c77ceec670887c1cd4947e4202aaeff3a9b2 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 25 May 2026 17:28:22 +0530 Subject: [PATCH 2/4] fix: update daemon process command to include admin subcommand --- src/authsome/cli/daemon_control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authsome/cli/daemon_control.py b/src/authsome/cli/daemon_control.py index b46d8056..e7ffc856 100644 --- a/src/authsome/cli/daemon_control.py +++ b/src/authsome/cli/daemon_control.py @@ -99,7 +99,7 @@ def start_daemon() -> None: DAEMON_DIR.mkdir(parents=True, exist_ok=True) log = LOG_FILE.open("ab") process = subprocess.Popen( - [sys.executable, "-m", "authsome.cli.main", "daemon", "serve"], + [sys.executable, "-m", "authsome.cli.main", "admin", "daemon", "serve"], stdout=log, stderr=log, start_new_session=True, From 13a25f7c768c748a63ed61b12921c7733a9f0b8a Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 25 May 2026 17:57:48 +0530 Subject: [PATCH 3/4] refactor: remove rekey command from admin CLI --- src/authsome/cli/admin.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/authsome/cli/admin.py b/src/authsome/cli/admin.py index 9ff10761..9cd93ed8 100644 --- a/src/authsome/cli/admin.py +++ b/src/authsome/cli/admin.py @@ -64,21 +64,6 @@ async def log_cmd(ctx_obj: ContextObj, lines: int, raw: bool) -> None: ctx_obj.print_json({"log_file": str(audit_path), "entries": parsed}) -@admin.command(name="rekey") -@auth_command -async def rekey(ctx_obj: ContextObj) -> None: - """Generate a new master key and re-encrypt all stored credentials in place.""" - actx = await ctx_obj.initialize() - try: - await actx.runtime_client.rekey() - ctx_obj.print_json({"status": "success", "message": "Master key successfully rotated"}) - logger.info("client_event event=rekey status=success") - except Exception: - if not ctx_obj.json_output: - logger.warning("client_event event=rekey status=failure") - raise - - @admin.group(name="daemon") def daemon() -> None: """Manage the local Authsome daemon.""" From 7e3a63480c5aafe58291139871429b588e3092dc Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 25 May 2026 18:03:47 +0530 Subject: [PATCH 4/4] refactor: remove vault rekey functionality and associated endpoints --- src/authsome/cli/admin.py | 1 - src/authsome/cli/client.py | 3 - src/authsome/cli/main.py | 340 +++------------------------ src/authsome/server/routes/health.py | 22 +- src/authsome/vault/__init__.py | 50 +--- src/authsome/vault/crypto.py | 92 -------- tests/server/test_pop_auth.py | 33 --- tests/vault/test_rekey.py | 167 ------------- 8 files changed, 38 insertions(+), 670 deletions(-) delete mode 100644 tests/vault/test_rekey.py diff --git a/src/authsome/cli/admin.py b/src/authsome/cli/admin.py index 9cd93ed8..c18eea48 100644 --- a/src/authsome/cli/admin.py +++ b/src/authsome/cli/admin.py @@ -6,7 +6,6 @@ from pathlib import Path import click -from loguru import logger from authsome.cli.context import ContextObj from authsome.cli.daemon_control import ( diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index d8fde4ef..2cc8b8dd 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -283,9 +283,6 @@ async def resolve_credentials(self, **kwargs: Any) -> dict[str, Any]: async def whoami(self) -> dict[str, Any]: return await self._get("/whoami") - async def rekey(self) -> dict[str, Any]: - return await self._post("/rekey", {}) - async def doctor(self) -> dict[str, Any]: return await self.ready() diff --git a/src/authsome/cli/main.py b/src/authsome/cli/main.py index fb7b848b..cb12c67e 100644 --- a/src/authsome/cli/main.py +++ b/src/authsome/cli/main.py @@ -1,6 +1,6 @@ """Command-line interface for authsome.""" -import json as json_lib +import json import os import pathlib import sys @@ -25,7 +25,7 @@ setup_logging, ) from authsome.paths import get_client_log_path -from authsome.utils import connection_is_active, format_error_code, format_expires_at, redact +from authsome.utils import connection_is_active, format_error_code, redact @click.group() @@ -50,15 +50,6 @@ def cli(ctx: click.Context, verbose: bool, log_file: str) -> None: setup_logging(verbose=verbose, log_file=resolved) -def _render_encryption_backend(data: dict[str, Any]) -> str: - """Render configured mode plus effective master-key source for human output.""" - backend = data["encryption_backend"] - configured_mode = data.get("configured_encryption_mode") - if configured_mode: - return f"{backend} (mode: {configured_mode})" - return backend - - @cli.group(name="provider") def provider() -> None: """Manage provider definitions and provider-level operations.""" @@ -111,125 +102,7 @@ def build_provider_entry(provider_data, source: str) -> dict: bundled_out = [build_provider_entry(p, "bundled") for p in by_source["bundled"]] custom_out = [build_provider_entry(p, "custom") for p in by_source["custom"]] - - if ctx_obj.json_output: - ctx_obj.print_json({"bundled": bundled_out, "custom": custom_out}) - return - - rows: list[dict[str, Any]] = [] - for p in bundled_out + custom_out: - provider_label = f"{p['display_name']} [{p['name']}]" - if p["connections"]: - for conn in p["connections"]: - rows.append( - { - "provider_id": p["name"], - "provider": provider_label, - "source": p["source"], - "auth": p["auth_type"], - "connection": ( - f"{conn['connection_name']} (default)" - if conn.get("is_default") - else conn["connection_name"] - ), - "status": conn["status"], - "expires_at": conn.get("expires_at"), - "expires": format_expires_at(conn.get("expires_at")) or "-", - } - ) - else: - rows.append( - { - "provider_id": p["name"], - "provider": provider_label, - "source": p["source"], - "auth": p["auth_type"], - "connection": "-", - "status": "not_connected", - "expires_at": None, - "expires": "-", - } - ) - - if not rows: - ctx_obj.echo("No providers configured.") - return - - connected_provider_ids = {row["provider_id"] for row in rows if connection_is_active(row)} - connected_count = len(connected_provider_ids) - ctx_obj.echo(f"Providers: {len(bundled_out) + len(custom_out)} total, {connected_count} connected") - ctx_obj.echo("") - - headers = { - "provider": "Provider", - "source": "Source", - "auth": "Auth", - "connection": "Connection", - "status": "Status", - "expires": "Expires", - } - widths = { - key: max(len(headers[key]), *(len(row[key]) for row in rows)) - for key in ("provider", "source", "auth", "connection", "status", "expires") - } - - def pad_field(text: str, key: str, color: str | None = None, bold: bool = False, dim: bool = False) -> str: - if ctx_obj.no_color or (not color and not dim): - return f"{text:<{widths[key]}}" - styled = click.style(text, fg=color, bold=bold, dim=dim) - padding = " " * (widths[key] - len(text)) - return f"{styled}{padding}" - - def render_row(row: dict[str, Any], is_header: bool = False, is_divider: bool = False) -> str: - if is_header or is_divider: - return ( - f"{row['provider']:<{widths['provider']}} " - f"{row['source']:<{widths['source']}} " - f"{row['auth']:<{widths['auth']}} " - f"{row['connection']:<{widths['connection']}} " - f"{row['status']:<{widths['status']}} " - f"{row['expires']:<{widths['expires']}}" - ).rstrip() - - is_active = connection_is_active(row) - - if is_active: - prov_color = "green" - prov_bold = True - conn_color = "cyan" - status_color = "green" - status_dim = False - expires_color = "yellow" - else: - prov_color = None - prov_bold = False - conn_color = None - expires_color = None - if row["status"] == "not_connected": - status_color = None - status_dim = True - else: - status_color = "red" - status_dim = False - - provider_str = pad_field(row["provider"], "provider", color=prov_color, bold=prov_bold) - source_str = pad_field(row["source"], "source") - auth_str = pad_field(row["auth"], "auth") - connection_str = pad_field(row["connection"], "connection", color=conn_color) - status_str = pad_field(row["status"], "status", color=status_color, bold=is_active, dim=status_dim) - expires_str = pad_field(row["expires"], "expires", color=expires_color) - - return f"{provider_str} {source_str} {auth_str} {connection_str} {status_str} {expires_str}".rstrip() - - ctx_obj.emit(render_row(headers, is_header=True)) - ctx_obj.emit( - render_row( - {key: "-" * widths[key] for key in ("provider", "source", "auth", "connection", "status", "expires")}, - is_divider=True, - ) - ) - for row in rows: - ctx_obj.emit(render_row(row)) + ctx_obj.print_json({"bundled": bundled_out, "custom": custom_out}) @cli.command() @@ -259,11 +132,6 @@ async def login( flow_value = FlowType(flow).value if flow else None scope_list = [s.strip() for s in scopes.split(",")] if scopes else None - if force and not ctx_obj.json_output and not ctx_obj.quiet: - ctx_obj.echo("Warning: Forcing login will overwrite any existing connection.", color="yellow") - if not ctx_obj.json_output: - ctx_obj.echo(f"Starting login for {provider}...", color="cyan") - try: session_info = await actx.runtime_client.start_login( provider=provider, @@ -273,7 +141,6 @@ async def login( base_url=base_url, force=force, ) - session_id = session_info["id"] status = session_info.get("status") login_result = {"status": "started", "record_status": status} @@ -285,9 +152,6 @@ async def login( if action_type == "open_url": auth_url = next_action["url"] - if not ctx_obj.json_output and not ctx_obj.quiet: - ctx_obj.echo("Opening browser to continue login...", color="cyan") - ctx_obj.echo(f"Visit: {auth_url}", color="cyan") import webbrowser try: @@ -295,13 +159,6 @@ async def login( except Exception: pass - if not ctx_obj.json_output and not ctx_obj.quiet: - ctx_obj.echo( - "\nLogin process started. The connection will be updated automatically once complete.", - color="yellow", - ) - ctx_obj.echo(f"Session ID: {session_id}") - logger.info( "client_event event=login provider={} connection={} flow={} status={}", provider, @@ -310,31 +167,16 @@ async def login( login_result["status"], ) except Exception: - if not ctx_obj.json_output: - logger.warning("client_event event=login provider={} connection={} status=failure", provider, connection) raise - if ctx_obj.json_output: - ctx_obj.print_json( - { - "status": login_result.get("status", "success"), - "provider": provider, - "connection": connection, - "record_status": login_result.get("record_status"), - } - ) - elif login_result.get("status") == "success": - ctx_obj.echo( - f"Already logged in to {provider} ({connection}). Use the --force flag to overwrite and open the browser.", - color="green", - ) - elif login_result.get("status") == "started": - ctx_obj.echo( - f"Login started for {provider} ({connection}). Run 'authsome provider list' to verify completion.", - color="green", - ) - else: - ctx_obj.echo(f"Successfully logged in to {provider} ({connection}).", color="green") + ctx_obj.print_json( + { + "status": login_result.get("status", "success"), + "provider": provider, + "connection": connection, + "record_status": login_result.get("record_status"), + } + ) @cli.command(name="scan") @@ -457,27 +299,15 @@ async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: item["env_var"], ) - if ctx_obj.json_output: - ctx_obj.print_json( - { - "connection": connection, - "import": should_import, - "configured_count": len(configured), - "imported_count": imported, - "results": results, - } - ) - else: - if not results: - ctx_obj.echo("No API key providers found to process.", color="yellow") - else: - for item in results: - env_hint = f" ({item['env_var']})" if item.get("env_var") else "" - source_hint = f" from {item['source']}" if item.get("source") else "" - ctx_obj.echo(f"{item['provider']}: {item['status']}{env_hint}{source_hint}") - if configured and not should_import: - ctx_obj.echo("Import skipped by user.", color="yellow") - ctx_obj.echo(f"Imported {imported} provider(s).", color="green") + ctx_obj.print_json( + { + "connection": connection, + "import": should_import, + "configured_count": len(configured), + "imported_count": imported, + "results": results, + } + ) @cli.command() @@ -490,10 +320,7 @@ async def logout(ctx_obj: ContextObj, provider: str, connection: str) -> None: await actx.runtime_client.logout(provider, connection) logger.info("client_event event=logout provider={} connection={}", provider, connection) - if ctx_obj.json_output: - ctx_obj.print_json({"status": "logged_out", "provider": provider, "connection": connection}) - else: - ctx_obj.echo(f"Logged out of {provider} ({connection}).", color="green") + ctx_obj.print_json({"status": "logged_out", "provider": provider, "connection": connection}) @connections.command(name="set-default") @@ -504,10 +331,7 @@ async def set_default_connection(ctx_obj: ContextObj, provider: str, connection: """Set the default CONNECTION for PROVIDER.""" actx = await ctx_obj.initialize() await actx.runtime_client.set_default_connection(provider, connection) - if ctx_obj.json_output: - ctx_obj.print_json({"status": "ok", "provider": provider, "default_connection": connection}) - else: - ctx_obj.echo(f"Default connection for {provider} set to {connection}.", color="green") + ctx_obj.print_json({"status": "ok", "provider": provider, "default_connection": connection}) @provider.command() @@ -519,10 +343,7 @@ async def revoke(ctx_obj: ContextObj, provider: str) -> None: await actx.runtime_client.revoke(provider) logger.info("client_event event=revoke provider={} connection=all", provider) - if ctx_obj.json_output: - ctx_obj.print_json({"status": "revoked", "provider": provider}) - else: - ctx_obj.echo(f"Revoked all credentials for {provider}.", color="green") + ctx_obj.print_json({"status": "revoked", "provider": provider}) @provider.command() @@ -534,10 +355,7 @@ async def remove(ctx_obj: ContextObj, provider: str) -> None: await actx.runtime_client.remove(provider) logger.info("client_event event=remove provider={} connection=all", provider) - if ctx_obj.json_output: - ctx_obj.print_json({"status": "removed", "provider": provider}) - else: - ctx_obj.echo(f"Removed provider {provider}.", color="green") + ctx_obj.print_json({"status": "removed", "provider": provider}) @connections.command(name="inspect") @@ -585,11 +403,8 @@ async def inspect_provider(ctx_obj: ContextObj, provider: str) -> None: data["connections"] = provider_group["connections"] break - if ctx_obj.json_output: - data.pop("schema_version", None) - ctx_obj.print_json(data) - else: - ctx_obj.echo(json_lib.dumps(data, indent=2)) + data.pop("schema_version", None) + ctx_obj.print_json(data) @cli.command(context_settings=dict(ignore_unknown_options=True)) @@ -617,32 +432,15 @@ async def register(ctx_obj: ContextObj, path: str, force: bool, yes: bool) -> No sys.exit(1) try: - data = json_lib.loads(filepath.read_text(encoding="utf-8")) + data = json.loads(filepath.read_text(encoding="utf-8")) definition = ProviderDefinition.model_validate(data) endpoints_to_check = _validate_provider_endpoints(definition) - if not ctx_obj.json_output and not ctx_obj.quiet and not yes and not force: - ctx_obj.echo(f"Registering '{definition.name}' provider:") - for name, val, _ in endpoints_to_check: - ctx_obj.echo(f" - {name}: {val}") - - if definition.oauth and definition.oauth.token_url: - prompt_msg = f"Register '{definition.name}' with token endpoint {definition.oauth.token_url}? [y/N]" - elif definition.api_url: - prompt_msg = f"Register '{definition.name}' with host {definition.api_url}? [y/N]" - else: - prompt_msg = f"Register '{definition.name}' provider? [y/N]" - - if not click.confirm(prompt_msg, default=False): - ctx_obj.echo("Registration aborted.", color="yellow") - sys.exit(0) - await actx.runtime_client.register_provider(definition.model_dump(mode="json"), force=force) endpoints = [ep for _, ep, _ in endpoints_to_check] logger.info("client_event event=register provider={} endpoints={}", definition.name, endpoints) - ctx_obj.print_json({"status": "registered", "provider": definition.name}) warnings = [] for name, val, is_host in endpoints_to_check: @@ -653,16 +451,12 @@ async def register(ctx_obj: ContextObj, path: str, force: bool, yes: bool) -> No if is_host and "://" not in target: target = f"https://{target}" - if not ctx_obj.quiet: - ctx_obj.echo(f"Testing reachability for {name}...", color="cyan") try: requests.head(target, timeout=5, allow_redirects=True) except requests.RequestException as e: warnings.append(f"{name} ({val}) is unreachable: {e}") - if warnings and not ctx_obj.quiet: - for warning in warnings: - ctx_obj.echo(f"Warning: {warning}", color="yellow") + ctx_obj.print_json({"status": "registered", "provider": definition.name, "warnings": warnings}) except Exception as exc: ctx_obj.print_json({"error": exc.__class__.__name__, "message": f"Failed to register provider: {exc}"}) sys.exit(format_error_code(exc)) @@ -691,13 +485,7 @@ async def init(ctx_obj: ContextObj) -> None: "effective_encryption_source": whoami_data.get("effective_encryption_source"), "encryption_backend": whoami_data.get("encryption_backend"), } - if ctx_obj.json_output: - ctx_obj.print_json(data) - else: - ctx_obj.echo(f"Initialized authsome at {home}", color="green") - ctx_obj.echo(f"Profile: {identity.handle}") - ctx_obj.echo(f"DID: {identity.did}") - ctx_obj.echo(f"Master Key Source: {_render_encryption_backend(whoami_data)}") + ctx_obj.print_json(data) @cli.group(name="profile") @@ -723,12 +511,7 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None: "registration_status": "registered" if identity_meta.registered else "local", "switched": True, } - if ctx_obj.json_output: - ctx_obj.print_json(data) - else: - ctx_obj.echo(f"Created local profile {identity_meta.handle}", color="green") - ctx_obj.echo("Switched to new profile") - ctx_obj.echo(f"DID: {identity_meta.did}") + ctx_obj.print_json(data) @profile.command(name="use") @@ -748,11 +531,7 @@ async def profile_use(ctx_obj: ContextObj, handle: str) -> None: "profile": identity_meta.handle, "did": identity_meta.did, } - if ctx_obj.json_output: - ctx_obj.print_json(data) - else: - ctx_obj.echo(f"Active profile: {data['profile']}", color="green") - ctx_obj.echo(f"DID: {data['did']}") + ctx_obj.print_json(data) @cli.command() @@ -804,36 +583,7 @@ async def whoami(ctx_obj: ContextObj) -> None: "issues": issues, } - if ctx_obj.json_output: - ctx_obj.print_json(data) - else: - ctx_obj.echo(f"Authsome Version: {data['authsome_version']}") - ctx_obj.echo(f"Home Directory: {data['home_directory']}") - ctx_obj.echo(f"Profile: {data['profile']}") - if data["principal_id"]: - ctx_obj.echo(f"Principal: {data['principal_id']}") - if data["vault_id"]: - ctx_obj.echo(f"Vault: {data['vault_id']}") - if data["did"]: - ctx_obj.echo(f"DID: {data['did']}") - if data["registration_status"]: - ctx_obj.echo(f"Registration: {data['registration_status']}") - ctx_obj.echo(f"Daemon URL: {data['daemon_url']}") - status_color = "green" if vault_status == "OK" else "red" - ctx_obj.echo(f"Encryption: {_render_encryption_backend(data)} [", nl=False) - ctx_obj.echo(vault_status, color=status_color, nl=False) - ctx_obj.echo("]") - - if issues: - ctx_obj.echo("\nIssues:", color="red") - for issue in issues: - ctx_obj.echo(f" - {issue}", color="red") - - ctx_obj.echo(f"\nConnected Providers: {data['connected_providers_count']}") - if connected_providers: - for p in sorted(connected_providers, key=lambda x: x["name"]): - suffix = "connection" if p["count"] == 1 else "connections" - ctx_obj.echo(f" {p['name']} ({p['count']} {suffix})") + ctx_obj.print_json(data) @cli.command() @@ -844,29 +594,9 @@ async def doctor(ctx_obj: ContextObj) -> None: results = await actx.doctor() all_ok = results.get("status") == "ready" - if ctx_obj.json_output: - ctx_obj.print_json(results) - if not all_ok: - sys.exit(1) - else: - for key, val in results.get("checks", {}).items(): - ok = val == "ok" - ctx_obj.emit(f"{key}: ", nl=False) - ctx_obj.emit("OK" if ok else "FAIL", color="green" if ok else "red") - issues = results.get("issues", []) - if issues: - ctx_obj.echo("\nIssues found:", color="red") - for issue in issues: - ctx_obj.echo(f" - {issue}", color="red") - - warnings = results.get("warnings", []) - if warnings: - ctx_obj.echo("\nWarnings:", color="yellow") - for warning in warnings: - ctx_obj.echo(f" - {warning}", color="yellow") - - if not all_ok: - sys.exit(1) + ctx_obj.print_json(results) + if not all_ok: + sys.exit(1) if __name__ == "__main__": diff --git a/src/authsome/server/routes/health.py b/src/authsome/server/routes/health.py index 39b66909..22fbbead 100644 --- a/src/authsome/server/routes/health.py +++ b/src/authsome/server/routes/health.py @@ -2,11 +2,9 @@ from __future__ import annotations -import asyncio -import secrets from typing import Literal, cast -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from authsome import __version__ from authsome.server.credential_service import AuthService @@ -111,24 +109,6 @@ async def ready( ) -_rekey_lock = asyncio.Lock() - - -@router.post("/rekey") -async def rekey( - request: Request, - auth: AuthService = Depends(get_protected_auth_service), -) -> dict[str, str]: - _ = request - async with _rekey_lock: - new_key_bytes = secrets.token_bytes(32) - try: - await auth.vault.rekey(new_key_bytes) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return {"status": "ok", "message": "Master key successfully rotated"} - - @router.get("/whoami") async def whoami( request: Request, diff --git a/src/authsome/vault/__init__.py b/src/authsome/vault/__init__.py index 43e722a5..3185231e 100644 --- a/src/authsome/vault/__init__.py +++ b/src/authsome/vault/__init__.py @@ -5,13 +5,10 @@ import builtins import json from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -from key_value.aio._utils.compound import uncompound_key -from loguru import logger +from typing import TYPE_CHECKING from authsome.store.interfaces import AppStore -from authsome.vault.crypto import VaultCrypto, create_crypto, create_rekey_crypto +from authsome.vault.crypto import VaultCrypto, create_crypto if TYPE_CHECKING: from authsome.vault.crypto import VaultCrypto @@ -110,49 +107,6 @@ async def check_integrity(self, *, identity: str | None = None) -> bool: _ = identity return await self._app_store.check_integrity() - async def rekey(self, new_key_bytes: bytes) -> None: - """Re-encrypt all encrypted keys in the underlying KV store using a new master key.""" - old_crypto = self.crypto - old_crypto.assert_rekey_supported() - - # 1. Create a new crypto instance using the new key - new_crypto = create_rekey_crypto(new_key_bytes) - - # 2. Iterate over all entries in the underlying DiskStore cache - # DiskStore stores compound keys as collection::key - cache = cast(Any, self._app_store.kv)._cache - # Collect all keys first to avoid iterating while modifying - all_compound_keys = list(cache.iterkeys()) - - # Perform in-place re-encryption - reencrypted_count = 0 - for comp_key in all_compound_keys: - try: - collection, key = uncompound_key(comp_key) - except Exception: - continue - - if collection == "config" or key == "__index__": - continue - - # Retrieve and decrypt the entry - val = await self._app_store.kv.get(key, collection=collection) - if val is not None and "data" in val: - ciphertext = val["data"] - # Decrypt with old crypto and re-encrypt with new crypto - plaintext = old_crypto.decrypt(ciphertext) - new_ciphertext = new_crypto.encrypt(plaintext) - # Store the re-encrypted value back - await self._app_store.kv.put(key, {"data": new_ciphertext}, collection=collection) - reencrypted_count += 1 - - # 3. Delegate persistence to the active backend - old_crypto.persist_rekeyed_key(new_key_bytes) - - # 4. Clear the active crypto in memory so it reloads on next access - self._crypto = None - logger.info("Rekey completed successfully. Re-encrypted {} keys.", reencrypted_count) - async def close(self) -> None: """Release resources.""" await self._app_store.close() diff --git a/src/authsome/vault/crypto.py b/src/authsome/vault/crypto.py index c57c9fd5..a1aca1c8 100644 --- a/src/authsome/vault/crypto.py +++ b/src/authsome/vault/crypto.py @@ -54,16 +54,6 @@ def decrypt(self, ciphertext: str) -> str: """Decrypt a compact ciphertext string and return plaintext.""" ... - @abstractmethod - def persist_rekeyed_key(self, new_key_bytes: bytes) -> None: - """Persist a newly rotated master key for this backend.""" - ... - - @abstractmethod - def assert_rekey_supported(self) -> None: - """Raise when this backend cannot perform an in-place rekey.""" - ... - def _encode(nonce: bytes, ct_with_tag: bytes) -> str: """Pack nonce + ciphertext+tag into a single dot-separated base64 string.""" @@ -141,27 +131,6 @@ def _load_key(self) -> bytes: logger.info("Generated new master key at {}", self._key_file) return master_key - def persist_rekeyed_key(self, new_key_bytes: bytes) -> None: - """Atomically replace the local master key file after a rekey.""" - _validate_master_key_bytes(new_key_bytes) - temp_path = self._key_file.with_suffix(".tmp") - key_data = { - "version": 1, - "key": base64.b64encode(new_key_bytes).decode("ascii"), - "algorithm": "AES-256-GCM", - "note": "Local master key for authsome. Protect this file.", - } - temp_path.write_text(json.dumps(key_data, indent=2), encoding="utf-8") - try: - os.chmod(temp_path, 0o600) - except OSError: - pass - temp_path.replace(self._key_file) - - def assert_rekey_supported(self) -> None: - """Local file storage supports in-place rekey.""" - return None - class KeyringCrypto(_AesGcmCrypto): """AES-256-GCM with master key stored in the OS keyring.""" @@ -183,26 +152,6 @@ def _load_key(self) -> bytes: raise EncryptionUnavailableError("OS keyring is unavailable and no master key could be created.") return master_key - def persist_rekeyed_key(self, new_key_bytes: bytes) -> None: - """Store a rotated master key in the OS keyring.""" - _validate_master_key_bytes(new_key_bytes) - key_b64_str = base64.b64encode(new_key_bytes).decode("ascii") - try: - import keyring as kr - except ImportError as exc: - raise RuntimeError( - "The 'keyring' package is required for keyring mode. Install it with: pip install keyring" - ) from exc - - try: - kr.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, key_b64_str) - except Exception as exc: - raise RuntimeError(f"Failed to store new master key in OS keyring: {exc}") from exc - - def assert_rekey_supported(self) -> None: - """OS keyring storage supports in-place rekey.""" - return None - class EnvVarCrypto(_AesGcmCrypto): """AES-256-GCM with master key supplied via AUTHSOME_MASTER_KEY.""" @@ -226,30 +175,12 @@ def _load_key(self) -> bytes: raise EncryptionUnavailableError(f"{_MASTER_KEY_ENV_VAR} is set but empty.") return _decode_master_key(raw_value.strip(), _MASTER_KEY_ENV_VAR) - def persist_rekeyed_key(self, new_key_bytes: bytes) -> None: - """Reject in-place rekey for externally supplied master keys.""" - _ = new_key_bytes - self.assert_rekey_supported() - - def assert_rekey_supported(self) -> None: - """Reject rekey for externally managed master keys.""" - raise ValueError( - "Vault rekey is unavailable while using AUTHSOME_MASTER_KEY. " - "Update the external master key and migrate data from a writable backend first." - ) - def _new_master_key() -> bytes: """Generate a new 256-bit master key.""" return secrets.token_bytes(_KEY_SIZE_BYTES) -def _validate_master_key_bytes(master_key: bytes) -> None: - """Validate already-decoded master key bytes.""" - if len(master_key) != _KEY_SIZE_BYTES: - raise EncryptionUnavailableError(f"Master key must be {_KEY_SIZE_BYTES} bytes; got {len(master_key)} bytes.") - - def _decode_master_key(encoded_value: str, source: str) -> bytes: """Decode and validate a base64-encoded master key.""" try: @@ -329,29 +260,6 @@ def _create_auto_crypto(key_file: Path | None) -> VaultCrypto: return LocalFileCrypto(key_file) -def create_rekey_crypto(new_key_bytes: bytes) -> VaultCrypto: - """Create an ephemeral in-memory backend for vault re-encryption.""" - - class _RekeyCrypto(_AesGcmCrypto): - @property - def source_id(self) -> str: - return "rekey" - - @property - def source_description(self) -> str: - return "In-memory rekey backend" - - def persist_rekeyed_key(self, new_key_bytes: bytes) -> None: - _ = new_key_bytes - raise RuntimeError("In-memory rekey backend cannot persist master keys") - - def assert_rekey_supported(self) -> None: - """This helper backend is never used as a persisted store.""" - raise RuntimeError("In-memory rekey backend cannot validate persisted rekey support") - - return _RekeyCrypto(new_key_bytes) - - def create_crypto(key_file: Path | None, mode: str = "auto") -> VaultCrypto: """Factory: return the appropriate VaultCrypto backend for the given mode.""" if mode == "auto": diff --git a/tests/server/test_pop_auth.py b/tests/server/test_pop_auth.py index 48868fdf..c8b3222e 100644 --- a/tests/server/test_pop_auth.py +++ b/tests/server/test_pop_auth.py @@ -89,39 +89,6 @@ def test_health_and_ready_report_encryption_details(monkeypatch, tmp_path: Path) assert "AUTHSOME_MASTER_KEY" in ready_response.json()["encryption_backend"] -def test_rekey_rotates_local_vault(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) - identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - - with TestClient(create_app()) as client: - client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) - resolved = asyncio.run(client.app.state.ownership_resolver.resolve(identity=identity.handle)) - asyncio.run(client.app.state.vault.put("key1", "secret-value-1", collection=f"vault:{resolved.vault_id}")) - - response = client.post("/rekey", json={}, headers=_auth_header(tmp_path, "POST", "/rekey", b"{}")) - - assert response.status_code == 200 - assert response.json()["status"] == "ok" - assert ( - asyncio.run(client.app.state.vault.get("key1", collection=f"vault:{resolved.vault_id}")) == "secret-value-1" - ) - - -def test_rekey_rejects_env_master_key(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(b"\x03" * 32).decode("ascii")) - monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) - identity = create_identity(tmp_path, "steady-wisely-boldly-0042") - - with TestClient(create_app()) as client: - client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) - response = client.post("/rekey", json={}, headers=_auth_header(tmp_path, "POST", "/rekey", b"{}")) - - assert response.status_code == 400 - assert "AUTHSOME_MASTER_KEY" in response.json()["detail"] - - def test_hosted_registration_requires_claim(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") diff --git a/tests/vault/test_rekey.py b/tests/vault/test_rekey.py deleted file mode 100644 index 185563fc..00000000 --- a/tests/vault/test_rekey.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Tests for the vault rekey / master key rotation functionality.""" - -from __future__ import annotations - -import base64 -import json -import secrets -from pathlib import Path - -import pytest - -from authsome.store.local import LocalAppStore -from authsome.vault import Vault -from authsome.vault.crypto import LocalFileCrypto - - -@pytest.mark.asyncio -class TestVaultRekey: - """Vault rekeying tests.""" - - async def test_rekey_local_key_mode(self, tmp_path: Path) -> None: - # 1. Initialize store and vault in local_key mode - app_store = LocalAppStore(tmp_path) - await app_store.ensure_initialized() - - master_key_path = tmp_path / "server" / "master.key" - vault = Vault( - app_store=app_store, - crypto_mode="local_key", - master_key_path=master_key_path, - ) - - # 2. Write some encrypted secrets into different collections - await vault.put("key1", "secret-value-1", collection="col1") - await vault.put("key2", "secret-value-2", collection="col1") - await vault.put("key3", "secret-value-3", collection="col2") - - # 3. Read them back to verify they are decrypted correctly - assert await vault.get("key1", collection="col1") == "secret-value-1" - assert await vault.get("key2", collection="col1") == "secret-value-2" - assert await vault.get("key3", collection="col2") == "secret-value-3" - - # Capture old key bytes - old_key_data = json.loads(master_key_path.read_text(encoding="utf-8")) - old_key_bytes = base64.b64decode(old_key_data["key"]) - - # 4. Generate a new key and perform rekeying - new_key_bytes = secrets.token_bytes(32) - await vault.rekey(new_key_bytes) - - # 5. Read them back with the rekeyed vault - assert await vault.get("key1", collection="col1") == "secret-value-1" - assert await vault.get("key2", collection="col1") == "secret-value-2" - assert await vault.get("key3", collection="col2") == "secret-value-3" - - # 6. Verify that the key file is updated - new_key_data = json.loads(master_key_path.read_text(encoding="utf-8")) - updated_key_bytes = base64.b64decode(new_key_data["key"]) - assert updated_key_bytes == new_key_bytes - assert updated_key_bytes != old_key_bytes - - # 7. Verify that trying to decrypt with old key directly fails - # Get raw ciphertext from store - raw_val = await app_store.kv.get("key1", collection="col1") - assert raw_val is not None - ciphertext = raw_val["data"] - - old_key_path = tmp_path / "server" / "old-master.key" - old_key_path.write_text( - json.dumps( - { - "version": 1, - "key": base64.b64encode(old_key_bytes).decode("ascii"), - "algorithm": "AES-256-GCM", - "note": "Local master key for authsome. Protect this file.", - } - ), - encoding="utf-8", - ) - old_crypto = LocalFileCrypto(old_key_path) - from authsome.errors import EncryptionUnavailableError - - with pytest.raises(EncryptionUnavailableError): - old_crypto.decrypt(ciphertext) - - # But new crypto decrypts it successfully! - new_key_path = tmp_path / "server" / "new-master.key" - new_key_path.write_text( - json.dumps( - { - "version": 1, - "key": base64.b64encode(new_key_bytes).decode("ascii"), - "algorithm": "AES-256-GCM", - "note": "Local master key for authsome. Protect this file.", - } - ), - encoding="utf-8", - ) - new_crypto = LocalFileCrypto(new_key_path) - assert new_crypto.decrypt(ciphertext) == "secret-value-1" - - await app_store.close() - - async def test_rekey_rejects_env_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - app_store = LocalAppStore(tmp_path) - await app_store.ensure_initialized() - monkeypatch.setenv("AUTHSOME_MASTER_KEY", base64.b64encode(secrets.token_bytes(32)).decode("ascii")) - - vault = Vault(app_store=app_store, crypto_mode="env") - await vault.put("key1", "secret-value-1", collection="col1") - - with pytest.raises(ValueError, match="AUTHSOME_MASTER_KEY"): - await vault.rekey(secrets.token_bytes(32)) - - assert await vault.get("key1", collection="col1") == "secret-value-1" - await app_store.close() - - async def test_rekey_keyring_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - # Mock keyring set/get methods - keyring_store: dict[str, str] = {} - - class MockKeyring: - @staticmethod - def set_password(service: str, username: str, password: str) -> None: - keyring_store[f"{service}:{username}"] = password - - @staticmethod - def get_password(service: str, username: str) -> str | None: - return keyring_store.get(f"{service}:{username}") - - import sys - - sys.modules["keyring"] = MockKeyring # type: ignore - - # 1. Initialize store and vault in keyring mode - app_store = LocalAppStore(tmp_path) - await app_store.ensure_initialized() - - # Seed initial key in keyring - initial_key = secrets.token_bytes(32) - MockKeyring.set_password( - "authsome", - "master_key", - base64.b64encode(initial_key).decode("ascii"), - ) - - vault = Vault( - app_store=app_store, - crypto_mode="keyring", - ) - - # 2. Write some encrypted secrets - await vault.put("key1", "keyring-secret", collection="col1") - assert await vault.get("key1", collection="col1") == "keyring-secret" - - # 3. Rekey the vault - new_key_bytes = secrets.token_bytes(32) - await vault.rekey(new_key_bytes) - - # 4. Verify keyring is updated and vault can still read the secret - stored_b64 = MockKeyring.get_password("authsome", "master_key") - assert stored_b64 is not None - assert base64.b64decode(stored_b64) == new_key_bytes - - assert await vault.get("key1", collection="col1") == "keyring-secret" - - await app_store.close()