Skip to content
Open
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
17 changes: 17 additions & 0 deletions docs/site/reference/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@ Authsome interacts with environment variables in three roles: **inputs** that ch
|----------|---------|
| `AUTHSOME_BASE_URL` | The daemon URL. On client machines, set this to point the CLI and proxy at a remote daemon instead of auto-starting one locally. On the daemon host, set this to the public URL when behind a reverse proxy — authsome uses it to build OAuth callback URLs such as `/auth/callback/oauth`. Defaults to `http://127.0.0.1:7998`. |
| `AUTHSOME_HOME` | Override the default `~/.authsome` directory. Useful for tests, ephemeral environments, and per-project vaults. |
| `AUTHSOME_IDENTITY_PRIVATE_KEY` | Hex-encoded Ed25519 private key. The agent's full identity is derived from this single value: the DID is computed locally and the human-readable handle is resolved from the identity server. Sufficient on its own — no `authsome init` or local identity files required. |
| `AUTHSOME_IDENTITY` | *(Optional, deprecated)* Explicit handle override used alongside `AUTHSOME_IDENTITY_PRIVATE_KEY`. No longer required: the handle is resolved from the identity server. See the migration note below. |
| `HTTP_PROXY` / `HTTPS_PROXY` | Honored by authsome's own outbound HTTP requests (token endpoints, device flow polling). The proxy started by `authsome run` is **set** as these variables in the child process; it does not chain through them. |

### Agent identity from a single key

For headless, CI, and container deployments, supply only the private key:

```bash
export AUTHSOME_IDENTITY_PRIVATE_KEY=<hex-encoded-ed25519-key>
uv run authsome list
```

On startup the CLI derives the DID from the key and asks the identity server for the handle bound to that DID. If the DID is not yet registered, a handle is generated and registered automatically, then the standard browser claim flow runs.

<Note>
**Migration:** the previous setup required both `AUTHSOME_IDENTITY` (handle) and `AUTHSOME_IDENTITY_PRIVATE_KEY`. The key alone is now sufficient. `AUTHSOME_IDENTITY` is still honored as an explicit handle override but is deprecated and no longer required. Resolving the handle requires the daemon to be reachable at CLI startup.
</Note>

### `AUTHSOME_BASE_URL` for remote daemons

For remote daemon deployments:
Expand Down
39 changes: 35 additions & 4 deletions src/authsome/cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import authsome.errors as err_mod
from authsome.cli.identity import RuntimeIdentity
from authsome.config import get_authsome_config
from authsome.identity.helpers import generate_handle
from authsome.identity.proof import POP_AUTH_SCHEME, create_proof_jwt
from authsome.server.config import get_server_config

Expand Down Expand Up @@ -130,6 +131,8 @@ async def _request(

async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, str]:
identity = await self.ensure_identity_ready()
if identity.handle is None:
raise RuntimeError("Identity handle could not be resolved from the identity server")
token = create_proof_jwt(
private_key=identity.signer,
issuer=identity.did,
Expand All @@ -150,6 +153,9 @@ async def ensure_identity_ready(self) -> RuntimeIdentity:
runtime = self._runtime_identity()
if self._server_registered:
return runtime
if runtime.handle is None:
runtime = await self._resolve_env_identity(runtime)
self._identity = runtime
await self._check_server_registration(runtime)
self._server_registered = True
return runtime
Expand All @@ -159,23 +165,38 @@ def _runtime_identity(self) -> RuntimeIdentity:
self._identity = RuntimeIdentity.load(self._home)
return self._identity

async def _resolve_env_identity(self, runtime: RuntimeIdentity) -> RuntimeIdentity:
"""Resolve a handle-less env identity's handle from the identity server.

Looks up the handle bound to the DID. When the DID is unknown the agent
is brand new, so a handle is generated; the existing registration/claim
flow then registers it.
"""
handle = await self.resolve_handle_by_did(runtime.did)
if handle is None:
handle = generate_handle()
return runtime.model_copy(update={"handle": handle})

async def _check_server_registration(self, runtime: RuntimeIdentity) -> None:
"""Verify registration with the server; register and claim if needed."""
handle = runtime.handle
if handle is None:
raise RuntimeError("Identity handle could not be resolved from the identity server")
try:
identity_status = await self.get_identity_status(runtime.handle)
identity_status = await self.get_identity_status(handle)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != status.HTTP_404_NOT_FOUND:
raise
identity_status = await self.register_identity(runtime.handle, runtime.did)
identity_status = await self.register_identity(handle, runtime.did)

reg_status = identity_status.get("registration_status", "")
if reg_status == "claim_required":
claim_url = identity_status.get("claim_url", "")
if claim_url:
self._open_claim_url(claim_url)
await self._poll_claim_completion(runtime.handle)
await self._poll_claim_completion(handle)
elif reg_status == "rejected":
raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server")
raise RuntimeError(f"Agent '{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 agent:\n {claim_url}", file=sys.stderr)
Expand Down Expand Up @@ -259,6 +280,16 @@ async def register_identity(self, handle: str, did: str) -> dict[str, Any]:
async def get_identity_status(self, handle: str) -> dict[str, Any]:
return await self._get(f"{API_PREFIX}/identities/{handle}", protected=False)

async def resolve_handle_by_did(self, did: str) -> str | None:
"""Return the handle the identity server has bound to ``did``, or None if unknown."""
try:
payload = await self._get(f"{API_PREFIX}/identities/by-did/{did}", protected=False)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == status.HTTP_404_NOT_FOUND:
return None
raise
return payload.get("identity")

async def remove(self, provider: str) -> None:
await self._delete(f"{API_PREFIX}/providers/{provider}")

Expand Down
26 changes: 21 additions & 5 deletions src/authsome/cli/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@


class RuntimeIdentity(BaseModel):
"""Resolved acting identity for the current process."""
"""Resolved acting identity for the current process.

handle: str
``handle`` is server-registered metadata, not part of the cryptographic
identity. When only a private key is supplied via the environment the handle
is left unresolved (``None``) and filled in later from the identity server.
"""

handle: str | None = None
did: str
signer: Ed25519PrivateKey

Expand All @@ -35,21 +40,32 @@ class RuntimeIdentity(BaseModel):
def from_pkey(cls, handle: str, signer: Ed25519PrivateKey) -> Self:
return cls(handle=validate_handle(handle), did=public_key_to_did_key(signer.public_key()), signer=signer)

@classmethod
def from_env_private_key(cls, signer: Ed25519PrivateKey) -> Self:
"""Build a handle-less identity from a private key; the handle is resolved later."""
return cls(handle=None, did=public_key_to_did_key(signer.public_key()), signer=signer)

@classmethod
def from_filesystem(cls, home: Path, handle: str) -> Self:
metadata = cls.load_metadata(home, handle)
return cls(handle=metadata.handle, did=metadata.did, signer=cls.load_private_key(home, handle))

@classmethod
def load(cls, home: Path, env: Mapping[str, str] | None = None) -> Self:
"""Resolve the acting process identity from env or local identity files."""
"""Resolve the acting process identity from env or local identity files.

A private key alone is sufficient: the DID is derived locally and the
handle is resolved from the identity server before first use. Supplying
``AUTHSOME_IDENTITY`` alongside the key keeps the explicit handle.
"""
handle_override, private_key_hex = cls._env_identity_values(env)
if private_key_hex and not handle_override:
raise ValueError("AUTHSOME_IDENTITY_PRIVATE_KEY requires AUTHSOME_IDENTITY")

if handle_override and private_key_hex:
return cls.from_pkey(handle_override, private_key_from_hex(private_key_hex))

if private_key_hex:
return cls.from_env_private_key(private_key_from_hex(private_key_hex))

return cls.ensure_local(home, active_handle=handle_override)

@classmethod
Expand Down
8 changes: 8 additions & 0 deletions src/authsome/server/routes/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ async def register_identity(body: RegisterIdentityRequest, request: Request) ->
return registration_status.to_payload()


@router.get("/by-did/{did:path}")
async def resolve_identity_by_did(did: str, request: Request) -> dict[str, str]:
registration = await request.app.state.store.identity_registry.resolve_by_did(did)
if registration is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found")
return {"identity": registration.handle, "did": registration.did}


@router.get("/{handle}")
async def get_identity_status(handle: str, request: Request) -> dict[str, str]:
registration_status = await request.app.state.identity_bootstrap.get_identity_status(handle=handle)
Expand Down
8 changes: 8 additions & 0 deletions src/authsome/server/store/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@ async def register(self, *, handle: str, did: str) -> IdentityRegistration:

async def resolve(self, handle: str) -> IdentityRegistration | None:
row = await self._db.fetch_one("SELECT * FROM identity_registrations WHERE handle = ?", [handle])
return self._registration_from_row(row)

async def resolve_by_did(self, did: str) -> IdentityRegistration | None:
row = await self._db.fetch_one("SELECT * FROM identity_registrations WHERE did = ?", [did])
return self._registration_from_row(row)

@staticmethod
def _registration_from_row(row: Any | None) -> IdentityRegistration | None:
if row is None:
return None
return IdentityRegistration(
Expand Down
81 changes: 76 additions & 5 deletions tests/cli/test_client_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,85 @@ def fake_request(method, url, data=None, headers=None, timeout=None):


@pytest.mark.asyncio
async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_path: Path) -> None:
async def test_env_private_key_only_resolves_handle_from_server(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "00" * 32)
source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip()
RuntimeIdentity.key_path(tmp_path, source.handle).unlink()
RuntimeIdentity.metadata_path(tmp_path, source.handle).unlink()
monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex)
resolved_handle = "resolved-from-server-0001"
calls: list[tuple[str, str]] = []

client = AuthsomeApiClient("http://127.0.0.1:7998")
def fake_request(method, url, data=None, headers=None, timeout=None):
calls.append((method, url))
response = Mock()
response.status_code = status.HTTP_200_OK
response.raise_for_status.return_value = None
if "/api/identities/by-did/" in url:
response.json.return_value = {"identity": resolved_handle, "did": source.did}
elif f"/api/identities/{resolved_handle}" in url:
response.json.return_value = {"identity": resolved_handle, "registration_status": "claimed"}
else:
response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}}
return response

_patch_httpx_request(monkeypatch, fake_request)

identity = await AuthsomeApiClient("http://127.0.0.1:7998").ensure_identity_ready()

assert identity.handle == resolved_handle
assert identity.did == source.did
assert (
"GET",
f"http://127.0.0.1:7998/api/identities/by-did/{source.did}",
) in calls


@pytest.mark.asyncio
async def test_env_private_key_only_registers_generated_handle_when_did_unknown(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip()
RuntimeIdentity.key_path(tmp_path, source.handle).unlink()
RuntimeIdentity.metadata_path(tmp_path, source.handle).unlink()
monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex)
registered: dict = {}

def fake_request(method, url, data=None, headers=None, timeout=None):
response = Mock()
if "/api/identities/by-did/" in url:
response.status_code = status.HTTP_404_NOT_FOUND
response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND)
)
elif url.endswith("/api/identities/register"):
response.status_code = status.HTTP_200_OK
response.raise_for_status.return_value = None
registered.update(json.loads(data.decode("utf-8")))
response.json.return_value = {
"identity": registered["handle"],
"did": registered["did"],
"registration_status": "claimed",
}
elif "/api/identities/" in url and method == "GET":
response.status_code = status.HTTP_404_NOT_FOUND
response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=Mock(), response=Mock(status_code=status.HTTP_404_NOT_FOUND)
)
else:
response.status_code = status.HTTP_200_OK
response.raise_for_status.return_value = None
response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}}
return response

_patch_httpx_request(monkeypatch, fake_request)

identity = await AuthsomeApiClient("http://127.0.0.1:7998").ensure_identity_ready()

with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"):
await client.ensure_identity_ready()
assert identity.handle is not None
assert registered["handle"] == identity.handle
assert registered["did"] == source.did


@pytest.mark.asyncio
Expand Down
11 changes: 8 additions & 3 deletions tests/identity/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ def test_runtime_identity_creates_missing_handle_override(tmp_path: Path) -> Non
assert RuntimeIdentity.key_path(tmp_path, runtime.handle).exists()


def test_runtime_identity_rejects_private_key_without_handle(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"):
RuntimeIdentity.load(tmp_path, env={"AUTHSOME_IDENTITY_PRIVATE_KEY": "00" * 32})
def test_runtime_identity_env_private_key_only_defers_handle(tmp_path: Path) -> None:
source = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
private_key_hex = RuntimeIdentity.key_path(tmp_path, source.handle).read_text(encoding="utf-8").strip()

runtime = RuntimeIdentity.load(tmp_path, env={"AUTHSOME_IDENTITY_PRIVATE_KEY": private_key_hex})

assert runtime.handle is None
assert runtime.did == source.did
30 changes: 30 additions & 0 deletions tests/identity/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from authsome.cli.identity import RuntimeIdentity
from authsome.identity.principal import ClaimStatus
from authsome.server.store import ServerStore, create_server_store
from authsome.server.store.repositories import IdentityClaimRegistry
Expand Down Expand Up @@ -34,6 +35,35 @@ async def test_claim_creates_principal_and_default_vault(tmp_path: Path) -> None
await store.close()


@pytest.mark.asyncio
async def test_resolve_by_did_returns_registered_handle(tmp_path: Path) -> None:
store = await _store(tmp_path)

try:
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
await store.identity_registry.register(handle=identity.handle, did=identity.did)

resolved = await store.identity_registry.resolve_by_did(identity.did)

assert resolved is not None
assert resolved.handle == identity.handle
assert resolved.did == identity.did
finally:
await store.close()


@pytest.mark.asyncio
async def test_resolve_by_did_returns_none_for_unknown_did(tmp_path: Path) -> None:
store = await _store(tmp_path)

try:
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")

assert await store.identity_registry.resolve_by_did(identity.did) is None
finally:
await store.close()


@pytest.mark.asyncio
async def test_claim_is_immutable_for_existing_identity(tmp_path: Path) -> None:
store = await _store(tmp_path)
Expand Down
23 changes: 23 additions & 0 deletions tests/server/test_pop_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ def test_registration_requires_claim(monkeypatch, tmp_path: Path) -> None:
assert "/claim?" in response.json()["claim_url"]


def test_resolve_identity_by_did_returns_handle(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")

with create_server_test_client() as client:
client.post("/api/identities/register", json={"handle": identity.handle, "did": identity.did})
response = client.get(f"/api/identities/by-did/{identity.did}")

assert response.status_code == status.HTTP_200_OK
assert response.json()["identity"] == identity.handle
assert response.json()["did"] == identity.did


def test_resolve_identity_by_did_returns_404_for_unknown_did(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")

with create_server_test_client() as client:
response = client.get(f"/api/identities/by-did/{identity.did}")

assert response.status_code == status.HTTP_404_NOT_FOUND


def test_whoami_rejects_wrong_path_claim(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path))
identity = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042")
Expand Down
Loading