mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
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:
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user