Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/internal/cli-design-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ Docs say default: `~/.authsome/logs/authsome.log`.

---

### 1e. `profile` command exists but is not documented
### 1e. Legacy `profile` command is removed

`authsome profile` with subcommands `create` and `use` appears in the CLI but is absent from `docs/site/reference/cli.mdx`.
The local signing-key command surface is `authsome agent create` and `authsome agent use`.

---

Expand Down Expand Up @@ -224,7 +224,7 @@ If the behavior differs for bundled vs. custom providers, the `--help` text shou
| P1 | Fix `--quiet` — stop suppressing data output | Medium |
| P1 | Fix `--force` on `register` — imply `--yes` or document split | Small |
| P1 | Add `connection set-default` subgroup (or alias to match docs) | Small |
| P1 | Document `profile` command, `shell` export format, corrected `--log-file` path | Small |
| P1 | Document `shell` export format and corrected `--log-file` path | Small |
| P2 | Resolve `inspect` vs `get` overlap — pick a clear model | Medium |
| P2 | Add human-readable default to `inspect` and `daemon status` | Medium |
| P2 | Fix `daemon stop` — wait for actual stop before returning | Medium |
Expand Down
18 changes: 9 additions & 9 deletions docs/internal/manual-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,17 @@ uv run authsome whoami
**Expected (first run):** the command prints a claim URL to stderr, opens it in a
browser, and blocks while polling:
```
Open this URL in your browser to claim this identity:
Open this URL in your browser to claim this agent:
http://127.0.0.1:7998/claim?token=claim_<token>
```

**Human action:**
1. The browser opens the claim page automatically (open the printed URL yourself if it doesn't, e.g. on a headless box).
2. Register with an email + password — or log in if the account already exists. The first account on a fresh server becomes the **admin** Principal.
3. Confirm that the displayed identity handle is yours.
3. Confirm that the displayed agent handle is yours.
4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step.

**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).
**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `agent` (registered non-default agent handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`).

```bash
uv run authsome doctor
Expand Down Expand Up @@ -327,20 +327,20 @@ uv run authsome provider list # github connection gone

---

## 15. Profiles
## 15. Agents

```bash
uv run authsome profile create --handle work
uv run authsome agent create --handle work
```

**Expected:** `{"v": 1, "status": "created", "profile": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this profile triggers its own browser claim.
**Expected:** `{"v": 1, "status": "created", "agent": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this agent triggers its own browser claim.

```bash
uv run authsome profile use work
uv run authsome whoami # profile reflects "work" (claim required on first use)
uv run authsome agent use work
uv run authsome whoami # agent reflects "work" (claim required on first use)
```

**Expected:** `profile use` `{"status": "active", "profile": "work", ...}`.
**Expected:** `agent use` -> `{"status": "active", "agent": "work", ...}`.

---

Expand Down
16 changes: 8 additions & 8 deletions docs/site/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
| `connections` | Inspect and manage stored provider connections. |
| `daemon` | Manage the local Authsome daemon. |
| `doctor` | Run health checks on directory layout and encryption. |
| `init` | Initialize local storage and register a fresh profile. |
| `agent` | Manage local agents backed by signing keys. |
| `init` | Initialize local storage and register a fresh agent. |
| `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. |
| `profile` | Manage local profiles backed by identity keys. |
| `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. |
Expand All @@ -38,8 +38,8 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress
### `init` / `whoami` / `doctor`

```bash
authsome init # initialize local storage and register profile
authsome whoami # show identity context and encryption mode
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
```
Expand Down Expand Up @@ -153,14 +153,14 @@ Sets the default connection for a provider. The proxy and library calls use the
authsome connections set-default github work
```

### `profile`
### `agent`

```bash
authsome profile create # create a new local profile keypair
authsome profile use # switch the active local profile
authsome agent create # create a new local agent keypair
authsome agent use # switch the active local agent
```

Profiles are backed by Ed25519 identity keys at `~/.authsome/identities/`. Each profile has its own credential namespace in the vault.
Agents are backed by Ed25519 signing keys at `~/.authsome/identities/`. Credentials are scoped to the active vault, not to the agent key.

### `daemon`

Expand Down
12 changes: 6 additions & 6 deletions src/authsome/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def raise_for_error(response: httpx.Response) -> None:
try:
data = response.json()
if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle":
raise err_mod.IdentityNotRegisteredError("current identity") from exc
raise err_mod.IdentityNotRegisteredError("current agent") from exc
error_name = data.get("error")
message = data.get("message")
if error_name and message:
Expand Down Expand Up @@ -175,25 +175,25 @@ async def _check_server_registration(self, runtime: RuntimeIdentity) -> None:
self._open_claim_url(claim_url)
await self._poll_claim_completion(runtime.handle)
elif reg_status == "rejected":
raise RuntimeError(f"Identity '{runtime.handle}' claim was rejected by the server")
raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server")

def _open_claim_url(self, claim_url: str) -> None:
print(f"Open this URL in your browser to claim this identity:\n {claim_url}", file=sys.stderr)
print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr)
with suppress(Exception):
webbrowser.open(claim_url)

async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> None:
print("Waiting for identity to be claimed...", file=sys.stderr)
print("Waiting for agent to be claimed...", file=sys.stderr)
deadline = asyncio.get_running_loop().time() + timeout_seconds
while True:
status = await self.get_identity_status(handle)
reg_status = status.get("registration_status", "")
if reg_status == "claimed":
return
if reg_status == "rejected":
raise RuntimeError(f"Identity '{handle}' claim was rejected")
raise RuntimeError(f"Agent '{handle}' claim was rejected")
if asyncio.get_running_loop().time() >= deadline:
raise TimeoutError(f"Timed out waiting for identity '{handle}' to be claimed")
raise TimeoutError(f"Timed out waiting for agent '{handle}' to be claimed")
await asyncio.sleep(1)

async def _get(self, path: str, *, protected: bool = True) -> dict[str, Any]:
Expand Down
4 changes: 2 additions & 2 deletions src/authsome/cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""CLI command registration."""

import authsome.cli.commands.agent as agent_module
import authsome.cli.commands.connections as connections_module
import authsome.cli.commands.core as core_module
import authsome.cli.commands.daemon as daemon_module
import authsome.cli.commands.profile as profile_module
import authsome.cli.commands.provider as provider_module


Expand All @@ -19,5 +19,5 @@ def register_commands(cli) -> None:
cli.add_command(core_module.log_cmd)
cli.add_command(provider_module.provider)
cli.add_command(connections_module.connections)
cli.add_command(profile_module.profile)
cli.add_command(agent_module.agent)
cli.add_command(daemon_module.daemon)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Profile CLI commands."""
"""Local agent CLI commands."""

import click

Expand All @@ -9,42 +9,50 @@
from authsome.config import get_authsome_config


@click.group(name="profile")
def profile() -> None:
"""Manage local profiles backed by identity keys."""
@click.group(name="agent")
def agent() -> None:
"""Manage local agents backed by signing keys."""


@profile.command(name="create")
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local profile handle.")
@auth_command
async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None:
"""Create a local profile keypair."""
async def _create_agent(ctx_obj: ContextObj, handle: str | None) -> None:
home = get_authsome_config().home
identity = RuntimeIdentity.create(home, handle)

data = {
"status": "created",
"home": str(home),
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
"registration_status": "local",
"switched": True,
}
ctx_obj.print_json(data)


@profile.command(name="use")
@click.argument("handle")
@auth_command
async def profile_use(ctx_obj: ContextObj, handle: str) -> None:
"""Select the active local profile."""
async def _use_agent(ctx_obj: ContextObj, handle: str) -> None:
home = get_authsome_config().home
identity = RuntimeIdentity.from_filesystem(home, handle)
ClientConfig.load(home).model_copy(update={"active_identity": identity.handle}).save(home)

data = {
"status": "active",
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
}
ctx_obj.print_json(data)


@agent.command(name="create")
@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local agent handle.")
@auth_command
async def agent_create(ctx_obj: ContextObj, handle: str | None) -> None:
"""Create a local agent keypair."""
await _create_agent(ctx_obj, handle)


@agent.command(name="use")
@click.argument("handle")
@auth_command
async def agent_use(ctx_obj: ContextObj, handle: str) -> None:
"""Select the active local agent."""
await _use_agent(ctx_obj, handle)
7 changes: 4 additions & 3 deletions src/authsome/cli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None:
@click.command()
@auth_command
async def init(ctx_obj: ContextObj) -> None:
"""Initialize local storage and register a fresh profile."""
"""Initialize local storage and register a fresh agent."""
home = get_authsome_config().home
RuntimeIdentity.ensure_local(home)

Expand All @@ -280,7 +280,7 @@ async def init(ctx_obj: ContextObj) -> None:
data = {
"status": "initialized",
"home": str(home),
"profile": identity.handle,
"agent": identity.handle,
"did": identity.did,
"registration_status": "registered",
"configured_encryption_mode": whoami_data.get("configured_encryption_mode"),
Expand Down Expand Up @@ -319,10 +319,11 @@ async def whoami(ctx_obj: ContextObj) -> None:
issues.append(f"connections: {exc}")
vault_status = "ERROR"

agent = whoami_data.get("identity", whoami_data.get("active_identity"))
data = {
"authsome_version": whoami_data["version"],
"home_directory": whoami_data["home"],
"profile": whoami_data.get("identity", whoami_data.get("active_identity")),
"agent": agent,
"principal_id": whoami_data.get("principal_id"),
"vault_id": whoami_data.get("vault_id"),
"did": whoami_data.get("did"),
Expand Down
25 changes: 22 additions & 3 deletions src/authsome/server/account_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,30 @@ async def login(self, *, email: str, password: str) -> BrowserSession:
principal = await self._principals.get_by_email(self._normalize_email(email))
if principal is None or not principal.password_hash:
raise ValueError("Invalid email or password")
self._verify_password(principal.password_hash, password, message="Invalid email or password")
return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email)

async def change_password(
self,
*,
principal_id: str,
current_password: str,
new_password: str,
) -> PrincipalRecord:
principal = await self._principals.get(principal_id)
if principal is None or not principal.password_hash:
raise ValueError("Invalid current password")
self._verify_password(principal.password_hash, current_password, message="Invalid current password")
self._validate_password(new_password)
return await self._principals.update_password(principal_id, password_hash=self._hasher.hash(new_password))

def _verify_password(self, password_hash: str, password: str, *, message: str) -> None:
try:
self._hasher.verify(principal.password_hash, password)
self._hasher.verify(password_hash, password)
except (VerificationError, VerifyMismatchError) as exc:
raise ValueError("Invalid email or password") from exc
return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email)
raise ValueError(message) from exc
except ValueError as exc:
raise ValueError(message) from exc

@staticmethod
def _normalize_email(email: str) -> str:
Expand Down
34 changes: 34 additions & 0 deletions src/authsome/server/routes/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ def _account_auth_next_url(value: Any) -> str:
return next_url


def _append_query(url: str, values: dict[str, str]) -> str:
separator = "&" if "?" in url else "?"
return f"{url}{separator}{urlencode(values)}"


@router.post("/auth/providers/{provider_name}/connect", include_in_schema=False)
async def connect_provider( # noqa: PLR0913
provider_name: str,
Expand Down Expand Up @@ -243,6 +248,35 @@ async def register_account(
return response


@router.post("/auth/password", include_in_schema=False)
async def change_account_password(request: Request) -> Response:
await resolve_ui_request_identity(request)
principal_id = getattr(request.state, "ui_principal_id", None)
form = await request.form()
next_url = _account_auth_next_url(form.get("next") or "/settings?tab=security")
if not principal_id:
return RedirectResponse(url=_account_auth_entry_url(next_url), status_code=status.HTTP_303_SEE_OTHER)

try:
await request.app.state.account_auth_service.change_password(
principal_id=principal_id,
current_password=str(form.get("current_password", "")),
new_password=str(form.get("new_password", "")),
)
except ValueError as exc:
return RedirectResponse(
url=_append_query(next_url, {"password_error": str(exc)}),
status_code=status.HTTP_303_SEE_OTHER,
)

audit.emit_event("account.password_changed", principal_id=principal_id, status="success")
capture_event(getattr(request.state, "ui_email", ""), "account_password_changed", {"principal_id": principal_id})
return RedirectResponse(
url=_append_query(next_url, {"password_changed": "1"}),
status_code=status.HTTP_303_SEE_OTHER,
)


@router.post("/auth/login", include_in_schema=False)
async def login_account(
request: Request,
Expand Down
1 change: 0 additions & 1 deletion src/authsome/server/store/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ def export(self, batch: Sequence[Any]) -> LogRecordExportResult:
future = asyncio.run_coroutine_threadsafe(self._registry.insert_many(rows), self._loop)
with self._lock:
self._futures.append(future)
future.result()
except Exception as exc:
logger.warning("Could not persist audit events: {}", exc)
return LogRecordExportResult.FAILURE
Expand Down
4 changes: 2 additions & 2 deletions tests/cli/test_client_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ def fake_request(method, url, data=None, headers=None, timeout=None):


@pytest.mark.asyncio
async def test_bootstrapped_identity_is_saved_as_active_profile(monkeypatch, tmp_path: Path) -> None:
async def test_bootstrapped_identity_is_saved_as_active_agent(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
captured: dict = {}

Expand Down Expand Up @@ -322,7 +322,7 @@ async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_p


@pytest.mark.asyncio
async def test_env_identity_does_not_update_active_profile(monkeypatch, tmp_path: Path) -> None:
async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
stored = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
ClientConfig(active_identity=stored.handle).save(tmp_path)
Expand Down
Loading
Loading