feat!: replace init and scan with authsome onboard

Unifies first-run setup into a single idempotent command that registers
identity, completes claim, and imports API keys from env. Persists
--base-url in client config for remote daemon connections.

Closes #434

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Manoj Bajaj
2026-06-16 02:22:10 -07:00
parent 87eed80d77
commit d9936d5699
15 changed files with 308 additions and 264 deletions
+3 -3
View File
@@ -37,7 +37,7 @@ uv run ty check src/
The CLI entry point after install:
```bash
uv run authsome init
uv run authsome onboard
uv run authsome login github
uv run authsome provider list
```
@@ -144,9 +144,9 @@ vault:<vault_id>:<provider>:state
server:<provider>:client
```
**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key fields: `active_identity` (the handle of the current identity), `vault_id` (the active vault resolved at `authsome init`). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`).
**Config** (`GlobalConfig`) is stored in the KV store under `config/global`. Key fields: `active_identity` (the handle of the current identity), `vault_id` (the active vault resolved at `authsome onboard`). Encryption mode is set via `config.encryption.mode` (`local_key` or `keyring`).
**CLI (`src/authsome/cli/main.py`)** is Click-based. All commands support `--json` for machine-readable output. `authsome init` creates the local identity, registers it with the daemon, and writes `active_identity` to config.
**CLI (`src/authsome/cli/main.py`)** is Click-based. All commands support `--json` for machine-readable output. `authsome onboard` creates the local identity, registers it with the daemon, imports API keys from env, and writes `active_identity` to config.
## Agent skills
+2 -2
View File
@@ -202,7 +202,7 @@ _Avoid_: vault_name, vault_handle
**VaultHandle**: Human-readable name for a Vault (e.g., `default`). Used in UIs and CLI; the VaultId is authoritative in storage.
**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome init` initiates. Vault access is gated until the claim is accepted.
**IdentityClaimRecord**: Binding from an Identity (Handle) to a Principal (PrincipalId) with a `ClaimStatus`. Created when an authenticated Principal confirms the browser claim that `authsome onboard` initiates. Vault access is gated until the claim is accepted.
_Avoid_: Claim, IdentityRegistration (as claim), join request
**ClaimStatus**: Lifecycle state: `pending``accepted` | `rejected`.
@@ -211,7 +211,7 @@ _Avoid_: Claim, IdentityRegistration (as claim), join request
## Initialization & Claim Flow
There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome init` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use).
There is a single flow for every deployment — no deployment mode (see ADR 0007). `authsome onboard` creates an Identity and registers it; the daemon returns `registration_status = "claim_required"` with a browser **claim URL**. The user opens the URL and registers (or logs in) with **email + password**: the first Principal created on a server becomes `admin`, all subsequent Principals are `user`. The authenticated Principal then confirms the claim, which binds the Identity to the Principal and creates the Principal's default Vault. Until the claim is `accepted`, all vault operations return `403`. The CLI opens the claim URL automatically and polls for completion (and prints the URL to stderr for headless use).
---
+12
View File
@@ -123,6 +123,18 @@ See the [self-hosting guide](docs/guides/self-hosting.md) for volume backup, TLS
## Quick Start
Install and run first-time setup (identity, claim, and API key import from `.env`):
```bash
authsome onboard
```
For a remote daemon, pass `--base-url` once — it is saved in client config for later commands:
```bash
authsome onboard --base-url https://authsome.example.com
```
Add the authsome skill to your agent (claude, codex, cursor, hermes, etc.):
```bash
+1 -1
View File
@@ -57,7 +57,7 @@ uv run authsome doctor
**Expected:** Exit code `0`; JSON `{"v": 1, "status": "ready", "checks": {"spec_version": "ok", "store": "ok", "identity": "ok", "providers": "ok", "connections": "ok", "vault": "ok", "integrity": "ok"}, "issues": [], "warnings": [...]}`. The `warnings` array is non-empty on a fresh install with no connections (e.g. "no active provider connections found"). A non-`ready` status exits `1`.
> **Tip:** `authsome init` performs the same register + claim flow explicitly and prints an `{"status": "initialized", ...}` payload.
> **Tip:** `authsome onboard` performs register + claim and imports API keys from env in one step, printing a combined JSON payload.
---
@@ -22,7 +22,7 @@ An **Identity** does not own credentials directly. Instead, an Identity claims a
Here is how the initialization and claim flow works:
1. **Initialize Identity:** Running `authsome init` creates a new Identity and registers it with the daemon. The daemon returns a claim URL.
1. **Initialize Identity:** Running `authsome onboard` creates a new Identity and registers it with the daemon. The daemon returns a claim URL.
2. **Register/Login Principal:** The user opens the claim URL and signs in (with email and password). The first Principal created becomes the server `admin`, subsequent ones are `user`.
3. **Accept Claim:** The authenticated Principal confirms the claim, binding the Identity to the Principal and creating the Principal's default Vault.
4. **Access Granted:** Vault access is granted only after the claim is `accepted`.
+10 -4
View File
@@ -83,7 +83,7 @@ If `doctor` reports failures, see [Diagnose with doctor](/troubleshooting/doctor
## First-run initialization
On `authsome init`, authsome initializes its home directory at `~/.authsome/`, creates a generated identity handle and Ed25519 DID, and registers that identity with the local daemon:
On `authsome onboard`, authsome initializes its home directory at `~/.authsome/`, creates a generated identity handle and Ed25519 DID, registers that identity with the daemon, completes claim, and imports API keys from `.env` files and the process environment:
```text
~/.authsome/
@@ -98,7 +98,7 @@ On `authsome init`, authsome initializes its home directory at `~/.authsome/`, c
daemon/
```
On a fresh `init`, authsome resolves the master key source in this order:
On a fresh `onboard`, authsome resolves the master key source in this order:
1. `AUTHSOME_MASTER_KEY` from the environment, when set. The value must be a base64-encoded 32-byte key.
2. An existing OS keyring entry.
@@ -110,7 +110,13 @@ Override the home location with `AUTHSOME_HOME` for ephemeral or per-project set
```bash
export AUTHSOME_HOME=/var/lib/authsome
authsome init
authsome onboard
```
For a remote or self-hosted daemon, pass `--base-url` once; authsome saves it in client config for later commands:
```bash
authsome onboard --base-url https://authsome.example.com
```
@@ -129,7 +135,7 @@ By default, authsome uses `encryption.mode = "auto"` and applies the precedence
Re-run `authsome doctor` to confirm the backend is reachable. The trade-offs are covered in [Encryption at rest](/security/encryption).
Older installs that used the implicit `default` profile must run `authsome init` again. This release does not migrate credentials under old `profile:default:*` keys.
Older installs that used the implicit `default` profile must run `authsome onboard` again. This release does not migrate credentials under old `profile:default:*` keys.
## Optional: trust the proxy CA
+20 -31
View File
@@ -13,13 +13,12 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
| `daemon` | Manage the local Authsome daemon. |
| `doctor` | Run health checks on directory layout and encryption. |
| `agent` | Manage local agents backed by signing keys. |
| `init` | Initialize local storage and register a fresh agent. |
| `onboard` | First-run setup: identity, claim, and API key import from env. |
| `log` | View structured audit entries or the raw client debug log. |
| `login <provider>` | Authenticate with PROVIDER using the configured flow. |
| `logout <provider>` | Log out of the specified PROVIDER connection. |
| `provider` | Manage provider definitions and provider-level operations. |
| `run -- <cmd>` | Run COMMAND as a subprocess injected with authentication credentials. |
| `scan` | Scan env files and process env for provider API keys. |
| `whoami` | Show basic local context. |
## Global flags
@@ -35,15 +34,28 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
## Command details
### `init` / `whoami` / `doctor`
### `onboard` / `whoami` / `doctor`
```bash
authsome init # initialize local storage and register agent
authsome whoami # show agent context and encryption mode
authsome doctor # run health checks
authsome doctor --json # structured output for monitoring
authsome onboard # identity + claim + import API keys from env
authsome onboard --base-url https://host:7998 # point at a remote daemon (saved for later runs)
authsome onboard --scan-only # drift report without importing
authsome whoami # show agent context and encryption mode
authsome doctor # run health checks
authsome doctor --json # structured output for monitoring
```
`onboard` is idempotent — safe to re-run. It creates a local identity when needed, completes daemon registration and claim, then imports API keys discovered in `.env` files and the process environment. OAuth providers still require `authsome login <provider>`.
When `--base-url` is passed, the URL is persisted in `~/.authsome/client/config.json` as `daemon_base_url` and used automatically on subsequent commands (unless overridden by `AUTHSOME_BASE_URL`).
| Option | Description |
|--------|-------------|
| `--base-url <url>` | Daemon URL to connect to; saved in client config for future commands. |
| `--scan-only` | Report env/vault drift without importing discovered keys. |
`onboard` does not support `--quiet`. Use `--json` for headless contexts.
`doctor` runs six checks and exits `0` when all pass:
| Check | What it verifies |
@@ -118,29 +130,6 @@ How it works:
See [Proxy injection](/concepts/proxy-injection) for the routing contract.
### `scan`
```bash
authsome scan [OPTIONS]
```
Scans `.env` files in the current directory tree and the active process environment for credentials matching bundled providers. By default it prints a drift report (what's in your env vs. what's in the vault).
| Option | Description |
|--------|-------------|
| `--import` | Apply the discovered values, creating connections in the vault. |
| `--connection <name>` | Target a non-default connection name when importing. |
| `--json` | Machine-readable drift report. Combine with `--import` to apply. |
```bash
authsome scan # report-only
authsome scan --json # report-only, JSON output
authsome scan --import # write discovered keys into the vault
authsome scan --import --connection ci # import into a named connection
```
`scan` does not support `--quiet`. Use `--json` for headless contexts.
### `connections set-default`
```bash
@@ -218,7 +207,7 @@ authsome log --raw # raw client debug log (loguru format)
authsome log --raw -n 20 # last 20 lines of the client debug log
```
Reads from `~/.authsome/server/logs/authsome.log` (the server-side structured audit log). Each entry records actions like `login`, `logout`, `revoke`, and `scan`, with fields: `timestamp`, `event`, `provider`, `connection`, `identity`, `status`.
Reads from `~/.authsome/server/logs/authsome.log` (the server-side structured audit log). Each entry records actions like `login`, `logout`, `revoke`, and `onboard`, with fields: `timestamp`, `event`, `provider`, `connection`, `identity`, `status`.
The `--raw` flag switches to the client-side debug log at `~/.authsome/client/logs/authsome.log` (loguru format, DEBUG level).
+1 -1
View File
@@ -162,7 +162,7 @@ The exact session shape depends on the flow type. For automation, prefer driving
### Manage Principals and Identities
CLI-level Principal and Identity switching is managed via `authsome init` and `authsome login`. The library provides programmatic access to these constructs, but typically you rely on the daemon resolving the active context.
CLI-level Principal and Identity switching is managed via `authsome onboard` and `authsome login`. The library provides programmatic access to these constructs, but typically you rely on the daemon resolving the active context.
### Register a custom provider
+1 -1
View File
@@ -32,7 +32,7 @@ A healthy machine prints `OK` for each check and exits with code `0`.
Causes:
- You haven't initialized authsome on this machine. Run `authsome init` to provision the home directory, identity, and master key.
- You haven't initialized authsome on this machine. Run `authsome onboard` to provision the home directory, identity, and master key.
- You moved or deleted `~/.authsome`. If you have a backup, restore it. Without the master key, encrypted records cannot be decrypted.
</Accordion>
+16 -5
View File
@@ -14,6 +14,7 @@ import httpx
from fastapi import status
import authsome.errors as err_mod
from authsome.cli.config import ClientConfig
from authsome.cli.identity import RuntimeIdentity
from authsome.config import get_authsome_config
from authsome.identity.proof import POP_AUTH_SCHEME, create_proof_jwt
@@ -22,11 +23,21 @@ from authsome.server.config import get_server_config
API_PREFIX = "/api"
def resolve_daemon_url(env: Mapping[str, str] | None = None) -> str:
"""Return the top-level configured Authsome server URL."""
configured = get_authsome_config().base_url
raw = (env or {}).get("AUTHSOME_BASE_URL", configured).strip()
return raw.rstrip("/") or configured
def resolve_daemon_url(env: Mapping[str, str] | None = None, home: Path | None = None) -> str:
"""Return the top-level configured Authsome server URL.
Precedence: ``AUTHSOME_BASE_URL`` env, then ``daemon_base_url`` in client
config (set by ``authsome onboard --base-url``), then the default from
``AuthsomeConfig``.
"""
configured = get_authsome_config(home).base_url.rstrip("/")
raw = (env or {}).get("AUTHSOME_BASE_URL", "").strip()
if raw:
return raw.rstrip("/") or configured
saved = ClientConfig.load(home).daemon_base_url
if saved:
return saved.rstrip("/")
return configured
def is_local_daemon_url(url: str) -> bool:
+1 -2
View File
@@ -10,10 +10,9 @@ import authsome.cli.commands.provider as provider_module
def register_commands(cli) -> None:
"""Attach command groups and root commands to the root CLI."""
cli.add_command(core_module.login)
cli.add_command(core_module.scan)
cli.add_command(core_module.onboard)
cli.add_command(core_module.logout)
cli.add_command(core_module.run)
cli.add_command(core_module.init)
cli.add_command(core_module.whoami)
cli.add_command(core_module.doctor)
cli.add_command(core_module.log_cmd)
+171 -142
View File
@@ -12,6 +12,7 @@ from authsome import FlowType
from authsome.auth.flows.browser import BrowserFlow
from authsome.auth.models.enums import AuthType
from authsome.auth.models.provider import ProviderDefinition
from authsome.cli.config import ClientConfig
from authsome.cli.context import ContextObj
from authsome.cli.helpers import (
_api_key_env_var,
@@ -48,6 +49,131 @@ def _build_login_json_payload(session_info: dict[str, Any], provider: str, conne
return payload
async def _run_credential_scan( # noqa: PLR0912, PLR0915
actx: ContextObj,
*,
connection: str,
auto_import: bool,
event_name: str,
) -> dict[str, Any]:
"""Scan env sources for API keys and optionally import them into the vault."""
scanned_env = _scan_env_sources()
provider_defs: list[ProviderDefinition] = []
connections = await actx.runtime_client.list_connections()
by_source = connections.get("by_source", {})
for source in ("bundled", "custom"):
for provider_data in by_source.get(source, []):
provider_defs.append(ProviderDefinition.model_validate(provider_data))
results: list[dict[str, Any]] = []
configured: list[dict[str, Any]] = []
for definition in provider_defs:
if definition.auth_type != AuthType.API_KEY:
continue
existing_record: dict[str, Any] | None = None
try:
existing_record = await actx.runtime_client.get_connection(definition.name, connection)
except Exception:
existing_record = None
existing_api_key = existing_record.get("api_key") if existing_record else None
existing_api_key = existing_api_key.strip() if isinstance(existing_api_key, str) else None
authsome_has_key = bool(existing_api_key and existing_record and existing_record.get("status") == "connected")
env_var = _api_key_env_var(definition)
if not env_var:
status = "no_env_mapping_authsome_present" if authsome_has_key else "no_env_mapping"
results.append({"provider": definition.name, "status": status})
continue
env_entry = scanned_env.get(env_var)
env_value_raw = env_entry[0] if env_entry else None
env_value = env_value_raw.strip() if isinstance(env_value_raw, str) and env_value_raw.strip() else None
source_name = env_entry[1] if env_entry else None
if env_value and authsome_has_key:
drift_status = "env_and_authsome_match" if env_value == existing_api_key else "env_and_authsome_different"
elif env_value and not authsome_has_key:
drift_status = "env_only"
elif not env_value and authsome_has_key:
drift_status = "authsome_only"
else:
drift_status = "both_missing"
results.append({"provider": definition.name, "status": drift_status, "env_var": env_var, "source": source_name})
if env_value is None:
continue
configured.append(
{
"provider": definition.name,
"env_var": env_var,
"source": source_name,
"api_key": env_value,
"drift": drift_status,
}
)
should_import = _scan_resolve_should_import(
auto_import=auto_import,
configured_count=len(configured),
json_output=actx.json_output,
quiet=actx.quiet,
)
imported = 0
if should_import:
for item in configured:
provider_name = item["provider"]
api_key_value = item["api_key"]
if item.get("drift") == "env_and_authsome_match":
results.append(
{
"provider": provider_name,
"status": "skipped_unchanged",
"env_var": item["env_var"],
"source": item.get("source"),
}
)
continue
session_info = await actx.runtime_client.start_login(
provider=provider_name,
connection=connection,
flow=FlowType.API_KEY.value,
force=True,
)
session_id = session_info["id"]
resume_info = await actx.runtime_client.resume_login_session(session_id, api_key=api_key_value)
if resume_info.get("status") != "completed":
session_status = resume_info.get("status")
raise RuntimeError(
f"Import did not complete for provider '{provider_name}' (session status: {session_status})."
)
imported += 1
results.append({"provider": provider_name, "status": "imported", "env_var": item["env_var"]})
logger.info(
"client_event event={} provider={} connection={} source={} source_env={} status=success",
event_name,
provider_name,
connection,
item["source"],
item["env_var"],
)
return {
"connection": connection,
"import": should_import,
"configured_count": len(configured),
"imported_count": imported,
"results": results,
}
@click.command()
@click.argument("provider")
@click.option("--connection", default="default", metavar="NAME", help="Connection name.")
@@ -111,134 +237,61 @@ async def login( # noqa: PLR0913
ctx_obj.print_json(login_result)
@click.command(name="scan")
@click.option("--connection", default="default", metavar="NAME", help="Connection name.")
@click.option("--import", "auto_import", is_flag=True, help="Import detected keys without interactive prompt.")
@click.command(name="onboard")
@click.option(
"--base-url",
metavar="URL",
help="Daemon base URL to use and persist in client config for future commands.",
)
@click.option(
"--scan-only",
is_flag=True,
help="Report env/vault drift without importing discovered keys.",
)
@auth_command
async def scan(ctx_obj: ContextObj, connection: str, auto_import: bool) -> None: # noqa: PLR0912, PLR0915
"""Scan env files and process env for provider API keys.
async def onboard(ctx_obj: ContextObj, base_url: str | None, scan_only: bool) -> None:
"""First-run setup: identity, claim, and API key import from env files.
Returns a drift report by default unless ``--import`` is also passed.
Idempotent safe to re-run. Discovered keys matching the vault are skipped.
"""
# TODO: Looks duplicated with helpers _scan_env_sources
if ctx_obj.quiet:
raise click.UsageError("'scan' does not support --quiet. Use the default JSON output or --import to apply.")
actx = await ctx_obj.initialize()
scanned_env = _scan_env_sources()
provider_defs: list[ProviderDefinition] = []
connections = await actx.runtime_client.list_connections()
by_source = connections.get("by_source", {})
for source in ("bundled", "custom"):
for provider_data in by_source.get(source, []):
provider_defs.append(ProviderDefinition.model_validate(provider_data))
results: list[dict[str, Any]] = []
configured: list[dict[str, Any]] = []
for definition in provider_defs:
if definition.auth_type != AuthType.API_KEY:
continue
existing_record: dict[str, Any] | None = None
try:
existing_record = await actx.runtime_client.get_connection(definition.name, connection)
except Exception:
existing_record = None
existing_api_key = existing_record.get("api_key") if existing_record else None
existing_api_key = existing_api_key.strip() if isinstance(existing_api_key, str) else None
authsome_has_key = bool(existing_api_key and existing_record and existing_record.get("status") == "connected")
env_var = _api_key_env_var(definition)
if not env_var:
status = "no_env_mapping_authsome_present" if authsome_has_key else "no_env_mapping"
results.append({"provider": definition.name, "status": status})
continue
env_entry = scanned_env.get(env_var)
env_value_raw = env_entry[0] if env_entry else None
env_value = env_value_raw.strip() if isinstance(env_value_raw, str) and env_value_raw.strip() else None
source_name = env_entry[1] if env_entry else None
if env_value and authsome_has_key:
drift_status = "env_and_authsome_match" if env_value == existing_api_key else "env_and_authsome_different"
elif env_value and not authsome_has_key:
drift_status = "env_only"
elif not env_value and authsome_has_key:
drift_status = "authsome_only"
else:
drift_status = "both_missing"
results.append({"provider": definition.name, "status": drift_status, "env_var": env_var, "source": source_name})
if env_value is None:
continue
configured.append(
{
"provider": definition.name,
"env_var": env_var,
"source": source_name,
"api_key": env_value,
"drift": drift_status,
}
raise click.UsageError(
"'onboard' does not support --quiet. Use the default JSON output or omit --scan-only to import."
)
should_import = _scan_resolve_should_import(
auto_import=auto_import,
configured_count=len(configured),
json_output=ctx_obj.json_output,
quiet=ctx_obj.quiet,
home = get_authsome_config().home
if base_url is not None:
normalized = base_url.strip().rstrip("/")
if not normalized:
raise click.UsageError("--base-url must be a non-empty URL.")
ClientConfig.load(home).model_copy(update={"daemon_base_url": normalized}).save(home)
RuntimeIdentity.ensure_local(home)
actx = await ctx_obj.initialize()
identity = await actx.runtime_client.ensure_identity_ready()
whoami_data = await actx.runtime_client.whoami()
client_config = ClientConfig.load(home)
scan_result = await _run_credential_scan(
actx,
connection="default",
auto_import=not scan_only,
event_name="onboard",
)
imported = 0
if should_import:
for item in configured:
provider_name = item["provider"]
api_key_value = item["api_key"]
if item.get("drift") == "env_and_authsome_match":
results.append(
{
"provider": provider_name,
"status": "skipped_unchanged",
"env_var": item["env_var"],
"source": item.get("source"),
}
)
continue
session_info = await actx.runtime_client.start_login(
provider=provider_name,
connection=connection,
flow=FlowType.API_KEY.value,
force=True,
)
session_id = session_info["id"]
resume_info = await actx.runtime_client.resume_login_session(session_id, api_key=api_key_value)
if resume_info.get("status") != "completed":
session_status = resume_info.get("status")
raise RuntimeError(
f"Import did not complete for provider '{provider_name}' (session status: {session_status})."
)
imported += 1
results.append({"provider": provider_name, "status": "imported", "env_var": item["env_var"]})
logger.info(
"client_event event=scan provider={} connection={} source={} source_env={} status=success",
provider_name,
connection,
item["source"],
item["env_var"],
)
ctx_obj.print_json(
{
"connection": connection,
"import": should_import,
"configured_count": len(configured),
"imported_count": imported,
"results": results,
"status": "onboarded",
"home": str(home),
"agent": identity.handle,
"did": identity.did,
"registration_status": "registered",
"daemon_base_url": client_config.daemon_base_url or actx.runtime_client.base_url,
"configured_encryption_mode": whoami_data.get("configured_encryption_mode"),
"effective_encryption_source": whoami_data.get("effective_encryption_source"),
"encryption_backend": whoami_data.get("encryption_backend"),
**scan_result,
}
)
@@ -266,30 +319,6 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None:
sys.exit(result.returncode)
@click.command()
@auth_command
async def init(ctx_obj: ContextObj) -> None:
"""Initialize local storage and register a fresh agent."""
home = get_authsome_config().home
RuntimeIdentity.ensure_local(home)
actx = await ctx_obj.initialize()
identity = await actx.runtime_client.ensure_identity_ready()
whoami_data = await actx.runtime_client.whoami()
data = {
"status": "initialized",
"home": str(home),
"agent": identity.handle,
"did": identity.did,
"registration_status": "registered",
"configured_encryption_mode": whoami_data.get("configured_encryption_mode"),
"effective_encryption_source": whoami_data.get("effective_encryption_source"),
"encryption_backend": whoami_data.get("encryption_backend"),
}
ctx_obj.print_json(data)
@click.command()
@auth_command
async def whoami(ctx_obj: ContextObj) -> None:
+1
View File
@@ -18,6 +18,7 @@ class ClientConfig(AuthsomeConfig):
"""
active_identity: str | None = None
daemon_base_url: str | None = None
proxy_ca_installed: bool = False
proxy_mode: ProxyMode = "connected_allow"
-60
View File
@@ -1,60 +0,0 @@
"""Tests for `authsome init`."""
import json
from pathlib import Path
from authsome import __version__
from authsome.cli.config import ClientConfig
from authsome.cli.identity import RuntimeIdentity
from authsome.cli.main import cli
def test_init_removes_legacy_default_state_and_registers_identity(
runner,
mock_client,
tmp_path: Path,
) -> None:
identities = tmp_path / "client" / "identities"
identities.mkdir(parents=True)
(identities / "default.json").write_text("{}", encoding="utf-8")
(identities / "default.key").write_text("legacy\n", encoding="utf-8")
created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
mock_client.ensure_identity_ready.return_value = created
result = runner.invoke(cli, ["--log-file", "", "init"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["agent"] != "default"
assert "profile" not in data
assert data["registration_status"] == "registered"
assert data["configured_encryption_mode"] == "auto"
assert data["effective_encryption_source"] == "local_key"
assert data["encryption_backend"] == "Local File (/home/test/.authsome/server/master.key)"
assert not (identities / "default.json").exists()
assert not (identities / "default.key").exists()
mock_client.ensure_identity_ready.assert_called_once()
mock_client.whoami.assert_called_once()
config_data = ClientConfig.load(tmp_path)
assert config_data.version == __version__
assert config_data.active_identity == data["agent"]
def test_init_skips_registration_for_registered_active_agent(
runner,
mock_client,
tmp_path: Path,
) -> None:
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
ClientConfig(active_identity=identity.handle).save(tmp_path)
result = runner.invoke(cli, ["--log-file", "", "init"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["agent"] == identity.handle
assert "profile" not in data
assert data["configured_encryption_mode"] == "auto"
mock_client.ensure_identity_ready.assert_called_once()
@@ -1,7 +1,11 @@
"""Tests for `authsome scan`."""
"""Tests for `authsome onboard`."""
import json
from pathlib import Path
from authsome import __version__
from authsome.cli.config import ClientConfig
from authsome.cli.identity import RuntimeIdentity
from authsome.cli.main import cli
@@ -28,10 +32,12 @@ def _oauth_provider(name: str) -> dict:
}
class TestScanCommand:
"""Behavior tests for scan and import workflow."""
class TestOnboardCommand:
"""Behavior tests for onboard setup and credential import."""
def test_scan_import_flag_imports_key_from_dotenv(self, runner, mock_client, monkeypatch, tmp_path) -> None:
def test_onboard_creates_identity_and_imports_key_from_dotenv(
self, runner, mock_client, monkeypatch, tmp_path: Path
) -> None:
mock_client.list_connections.return_value = {
"connections": [],
"by_source": {
@@ -42,20 +48,60 @@ class TestScanCommand:
mock_client.get_connection.side_effect = Exception("not found")
mock_client.start_login.return_value = {"id": "sess-1", "status": "pending"}
mock_client.resume_login_session.return_value = {"id": "sess-1", "status": "completed"}
created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
mock_client.ensure_identity_ready.return_value = created
monkeypatch.chdir(tmp_path)
(tmp_path / ".env").write_text("BREVO_API_KEY=test123\n", encoding="utf-8")
result = runner.invoke(cli, ["--log-file", "", "scan", "--import"])
result = runner.invoke(cli, ["--log-file", "", "onboard"])
assert result.exit_code == 0, result.output
mock_client.ensure_identity_ready.assert_called_once()
mock_client.whoami.assert_called_once()
mock_client.start_login.assert_called_once_with(
provider="brevo", connection="default", flow="api_key", force=True
)
mock_client.resume_login_session.assert_called_once_with("sess-1", api_key="test123")
data = json.loads(result.output)
assert data["status"] == "onboarded"
assert data["import"] is True
assert data["imported_count"] == 1
def test_scan_defaults_to_report_only(self, runner, mock_client, monkeypatch) -> None:
def test_onboard_removes_legacy_default_state(self, runner, mock_client, tmp_path: Path) -> None:
identities = tmp_path / "client" / "identities"
identities.mkdir(parents=True)
(identities / "default.json").write_text("{}", encoding="utf-8")
(identities / "default.key").write_text("legacy\n", encoding="utf-8")
created = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
mock_client.ensure_identity_ready.return_value = created
result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["agent"] != "default"
assert data["registration_status"] == "registered"
assert not (identities / "default.json").exists()
assert not (identities / "default.key").exists()
mock_client.ensure_identity_ready.assert_called_once()
config_data = ClientConfig.load(tmp_path)
assert config_data.version == __version__
assert config_data.active_identity == data["agent"]
def test_onboard_skips_registration_for_registered_active_agent(self, runner, mock_client, tmp_path: Path) -> None:
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
ClientConfig(active_identity=identity.handle).save(tmp_path)
result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["agent"] == identity.handle
mock_client.ensure_identity_ready.assert_called_once()
def test_onboard_scan_only_does_not_import(self, runner, mock_client, monkeypatch) -> None:
mock_client.list_connections.return_value = {
"connections": [],
"by_source": {"bundled": [_api_key_provider("openai", "OPENAI_API_KEY")], "custom": []},
@@ -63,7 +109,7 @@ class TestScanCommand:
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-value")
mock_client.get_connection.return_value = {}
result = runner.invoke(cli, ["--log-file", "", "scan"])
result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"])
assert result.exit_code == 0, result.output
mock_client.start_login.assert_not_called()
@@ -71,14 +117,14 @@ class TestScanCommand:
data = json.loads(result.output)
assert data["import"] is False
def test_scan_rejects_quiet_flag(self, runner, mock_client) -> None:
result = runner.invoke(cli, ["--log-file", "", "scan", "--quiet"])
def test_onboard_rejects_quiet_flag(self, runner, mock_client) -> None:
result = runner.invoke(cli, ["--log-file", "", "onboard", "--quiet"])
assert result.exit_code == 1
data = json.loads(result.output)
assert data["error"] == "UsageError"
mock_client.list_connections.assert_not_called()
def test_scan_reports_drift_states(self, runner, mock_client, monkeypatch) -> None:
def test_onboard_reports_drift_states(self, runner, mock_client, monkeypatch) -> None:
mock_client.list_connections.return_value = {
"connections": [],
"by_source": {
@@ -103,7 +149,7 @@ class TestScanCommand:
mock_client.get_connection.side_effect = _get_connection
result = runner.invoke(cli, ["--log-file", "", "scan"])
result = runner.invoke(cli, ["--log-file", "", "onboard", "--scan-only"])
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
@@ -111,3 +157,14 @@ class TestScanCommand:
assert statuses["openai"] == "env_and_authsome_different"
assert statuses["brevo"] == "authsome_only"
assert statuses["resend"] == "both_missing"
def test_onboard_persists_base_url_in_client_config(self, runner, mock_client, tmp_path: Path) -> None:
result = runner.invoke(
cli,
["--log-file", "", "onboard", "--base-url", "https://authsome.example.com", "--scan-only"],
)
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["daemon_base_url"] == "https://authsome.example.com"
assert ClientConfig.load(tmp_path).daemon_base_url == "https://authsome.example.com"