From 4bdb3f47c75d62ba1ca094f0f1f7013f8460c5bb Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 21 May 2026 20:03:02 +0530 Subject: [PATCH 1/6] feat: add support for environment-based identity configuration and implement client-side identity caching --- docs/site/installation.mdx | 9 +++++ src/authsome/cli/client.py | 21 ++++++++-- src/authsome/identity/local.py | 69 ++++++++++++++++++++++++++++++++ tests/cli/test_client_signing.py | 33 +++++++++++++++ tests/identity/test_identity.py | 35 ++++++++++++++++ 5 files changed, 164 insertions(+), 3 deletions(-) diff --git a/docs/site/installation.mdx b/docs/site/installation.mdx index 1316e77d..e8f370a1 100644 --- a/docs/site/installation.mdx +++ b/docs/site/installation.mdx @@ -113,6 +113,15 @@ export AUTHSOME_HOME=/var/lib/authsome authsome init ``` +For ephemeral agents or fresh CI runners, you can also supply the acting identity from the environment instead of relying on local identity files: + +```bash +export AUTHSOME_IDENTITY=steady-wisely-boldly-0042 +export AUTHSOME_IDENTITY_PRIVATE_KEY=<32-byte-ed25519-private-key-as-hex> +``` + +When `AUTHSOME_IDENTITY_PRIVATE_KEY` is set, authsome derives the `did:key` from that private key and uses the environment-backed identity before falling back to `~/.authsome/client/identities/`. + See [Filesystem layout](/reference/file-layout) for the full directory model. ## Choose the encryption backend diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index 7e0488cf..86124329 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -94,6 +94,7 @@ class AuthsomeApiClient: def __init__(self, base_url: str | None = None) -> None: self._base_url = (base_url or resolve_daemon_url()).rstrip("/") self._home = Path(os.environ.get("AUTHSOME_HOME", str(Path.home() / ".authsome"))) + self._identity_cache: IdentityMetadata | None = None @property def base_url(self) -> str: @@ -141,12 +142,21 @@ async def _proof_headers(self, method: str, path: str, body: bytes) -> dict[str, async def ensure_identity_ready(self) -> IdentityMetadata: """Ensure the acting identity is registered and, in hosted mode, claimed.""" - identity = ensure_local_identity(self._home, active_handle=_selected_identity_handle(self._home)) + selected_handle = _selected_identity_handle(self._home) + if self._identity_cache is not None and ( + selected_handle is None or self._identity_cache.handle == selected_handle + ): + if self._identity_cache.claimed: + return self._identity_cache + + identity = ensure_local_identity(self._home, active_handle=selected_handle) + self._identity_cache = identity status: dict[str, Any] | None = None if not identity.registered: status = await self.register_identity(identity.handle, identity.did) identity = mark_registered(self._home, identity.handle) + self._identity_cache = identity elif identity.claimed: return identity else: @@ -167,11 +177,16 @@ async def ensure_identity_ready(self) -> IdentityMetadata: except Exception: pass await self._poll_claim_completion(identity.handle) - return mark_claimed(self._home, identity.handle) + identity = mark_claimed(self._home, identity.handle) + self._identity_cache = identity + return identity if registration_status in {"claimed", "registered"}: - return mark_claimed(self._home, identity.handle) + identity = mark_claimed(self._home, identity.handle) + self._identity_cache = identity + return identity + self._identity_cache = identity return identity async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> dict[str, Any]: diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index df9d80ec..d529012c 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -6,6 +6,7 @@ import os import random import re +from collections.abc import Mapping from datetime import UTC, datetime from enum import StrEnum from pathlib import Path @@ -20,6 +21,8 @@ _ED25519_MULTICODEC_PREFIX = b"\xed\x01" _DID_KEY_PREFIX = "did:key:z" _HANDLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$") +_IDENTITY_HANDLE_ENV_VAR = "AUTHSOME_IDENTITY" +_IDENTITY_PRIVATE_KEY_ENV_VAR = "AUTHSOME_IDENTITY_PRIVATE_KEY" _ADJECTIVES = ( "brisk", @@ -151,15 +154,69 @@ def private_key_from_hex(value: str) -> Ed25519PrivateKey: return Ed25519PrivateKey.from_private_bytes(raw) +def _env_values(env: Mapping[str, str] | None = None) -> Mapping[str, str]: + return env if env is not None else os.environ + + +def _env_identity_handle(env: Mapping[str, str] | None = None) -> str | None: + raw_handle = _env_values(env).get(_IDENTITY_HANDLE_ENV_VAR) + if raw_handle is None: + return None + handle = raw_handle.strip() + if not handle: + raise ValueError(f"{_IDENTITY_HANDLE_ENV_VAR} is set but empty.") + return validate_handle(handle) + + +def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519PrivateKey | None: + raw_key = _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) + if raw_key is None: + return None + if not raw_key.strip(): + raise ValueError(f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is set but empty.") + return private_key_from_hex(raw_key) + + +def _load_env_identity(env: Mapping[str, str] | None = None) -> IdentityMetadata | None: + handle = _env_identity_handle(env) + private_key = _env_identity_private_key(env) + if private_key is None: + return None + if handle is None: + raise ValueError(f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} requires {_IDENTITY_HANDLE_ENV_VAR} to also be set.") + + now = datetime.now(UTC) + return IdentityMetadata( + handle=handle, + did=public_key_to_did_key(private_key.public_key()), + created_at=now, + updated_at=now, + ) + + +def _env_identity_matches(handle: str, env: Mapping[str, str] | None = None) -> bool: + env_identity = _load_env_identity(env) + return env_identity is not None and env_identity.handle == handle + + def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: + env_key = _env_identity_private_key() + env_handle = _env_identity_handle() + if env_key is not None and env_handle == handle: + return env_key return private_key_from_hex(identity_key_path(home, handle).read_text(encoding="utf-8")) def load_identity(home: Path, handle: str) -> IdentityMetadata: + env_identity = _load_env_identity() + if env_identity is not None and env_identity.handle == handle: + return env_identity return IdentityMetadata.model_validate_json(identity_metadata_path(home, handle).read_text(encoding="utf-8")) def identity_exists(home: Path, handle: str) -> bool: + if _env_identity_matches(handle): + return True return identity_metadata_path(home, handle).exists() and identity_key_path(home, handle).exists() @@ -236,6 +293,8 @@ def mark_registered(home: Path, handle: str) -> IdentityMetadata: updated = metadata.model_copy( update={"identity_status": IdentityStatus.REGISTERED, "updated_at": datetime.now(UTC)} ) + if _env_identity_matches(handle): + return updated identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated @@ -244,6 +303,8 @@ def mark_claimed(home: Path, handle: str) -> IdentityMetadata: """Persist a claimed state for a local identity after ownership resolution.""" metadata = load_identity(home, handle) updated = metadata.model_copy(update={"identity_status": IdentityStatus.CLAIMED, "updated_at": datetime.now(UTC)}) + if _env_identity_matches(handle): + return updated identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated @@ -251,6 +312,14 @@ def mark_claimed(home: Path, handle: str) -> IdentityMetadata: def ensure_local_identity(home: Path, active_handle: str | None = None) -> IdentityMetadata: """Return the active local identity, creating one if none exists.""" remove_legacy_default_identity(home) + env_identity = _load_env_identity() + if env_identity is not None: + if active_handle is not None and active_handle != env_identity.handle: + raise FileNotFoundError( + f"Configured identity '{active_handle}' does not match environment identity " + f"'{env_identity.handle}' from {_IDENTITY_HANDLE_ENV_VAR}." + ) + return env_identity if active_handle is None: active_handle = _read_active_identity_handle(home) if active_handle: diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index 3ea8e446..b83b3c8e 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -173,6 +173,39 @@ async def test_identity_env_override_wins_over_active_identity(monkeypatch, tmp_ assert identity.handle == "rapid-brightly-firmly-0007" +@pytest.mark.asyncio +async def test_env_backed_identity_is_cached_after_claim(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "05" * 32) + calls: list[tuple[str, str]] = [] + + def fake_request(method, url, data=None, headers=None, timeout=None): + calls.append((method, url)) + response = Mock() + response.raise_for_status.return_value = None + if url.endswith("/identities/register"): + response.json.return_value = { + "identity": "rapid-brightly-firmly-0007", + "registration_status": "claimed", + } + else: + response.json.return_value = {"connections": [], "by_source": {"bundled": [], "custom": []}} + return response + + monkeypatch.setattr("authsome.cli.client.requests.request", fake_request) + + client = AuthsomeApiClient("http://127.0.0.1:7998") + await client.list_connections() + await client.list_connections() + + assert calls == [ + ("POST", "http://127.0.0.1:7998/identities/register"), + ("GET", "http://127.0.0.1:7998/connections"), + ("GET", "http://127.0.0.1:7998/connections"), + ] + + @pytest.mark.asyncio async def test_start_login_bootstraps_identity_readiness(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index b8b600b1..4e6a4034 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -9,6 +9,7 @@ create_identity, ensure_local_identity, identity_key_path, + load_private_key, mark_claimed, mark_registered, public_key_from_did_key, @@ -83,3 +84,37 @@ async def test_current_from_home_uses_client_side_active_identity(tmp_path: Path identity = await current_from_home(tmp_path) assert identity.handle == first.handle + + +def test_ensure_local_identity_prefers_environment_identity(monkeypatch, tmp_path: Path) -> None: + create_identity(tmp_path, "steady-wisely-boldly-0042") + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + "02" * 32, + ) + + identity = ensure_local_identity(tmp_path) + + assert identity.handle == "rapid-brightly-firmly-0007" + assert identity.did.startswith("did:key:z6Mk") + assert identity.identity_status == IdentityStatus.UNREGISTERED + + +def test_load_private_key_prefers_environment_identity_key(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + "03" * 32, + ) + + private_key = load_private_key(tmp_path, "rapid-brightly-firmly-0007") + + assert public_key_to_did_key(private_key.public_key()).startswith("did:key:z6Mk") + + +def test_environment_identity_key_requires_handle(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "04" * 32) + + with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"): + ensure_local_identity(tmp_path) From bffe4bdcfe7500b170326d2abe3bcc7677c94232 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 21 May 2026 20:35:51 +0530 Subject: [PATCH 2/6] feat: raise clear error when environment-backed identity is missing private key --- src/authsome/identity/local.py | 12 ++++++++++++ tests/identity/test_identity.py | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index d529012c..373d310f 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -199,6 +199,11 @@ def _env_identity_matches(handle: str, env: Mapping[str, str] | None = None) -> return env_identity is not None and env_identity.handle == handle +def _has_env_identity_handle_without_private_key(env: Mapping[str, str] | None = None) -> bool: + handle = _env_identity_handle(env) + return handle is not None and _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) is None + + def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: env_key = _env_identity_private_key() env_handle = _env_identity_handle() @@ -324,6 +329,13 @@ def ensure_local_identity(home: Path, active_handle: str | None = None) -> Ident active_handle = _read_active_identity_handle(home) if active_handle: if not identity_exists(home, active_handle): + if active_handle == _env_identity_handle() and _has_env_identity_handle_without_private_key(): + raise FileNotFoundError( + f"{_IDENTITY_HANDLE_ENV_VAR} is set to '{active_handle}' but " + f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is not set, and no local identity files were found at " + f"{identities_dir(home)}. Export {_IDENTITY_PRIVATE_KEY_ENV_VAR} to use the environment-backed " + "identity, or run 'authsome init' to create and register a new local identity." + ) raise FileNotFoundError( f"Configured identity '{active_handle}' not found at {identities_dir(home)}. " "Run 'authsome init' to create and register a new identity." diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index 4e6a4034..0138d111 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -118,3 +118,10 @@ def test_environment_identity_key_requires_handle(monkeypatch, tmp_path: Path) - with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"): ensure_local_identity(tmp_path) + + +def test_environment_identity_handle_without_private_key_has_clear_error(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_IDENTITY", "calm-clearly-quickly-1216") + + with pytest.raises(FileNotFoundError, match="AUTHSOME_IDENTITY_PRIVATE_KEY"): + ensure_local_identity(tmp_path, active_handle="calm-clearly-quickly-1216") From 290cd8fb53f547206fc53b669675d2722eaa986b Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 21 May 2026 20:42:25 +0530 Subject: [PATCH 3/6] feat: implement centralized validation for environment-based identity configuration and update identity loading logic --- src/authsome/identity/local.py | 41 +++++++++++++++++++++----------- tests/cli/test_client_signing.py | 9 +++++-- tests/identity/test_identity.py | 6 ++--- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index 373d310f..c48afddb 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -168,6 +168,10 @@ def _env_identity_handle(env: Mapping[str, str] | None = None) -> str | None: return validate_handle(handle) +def _has_env_identity_handle(env: Mapping[str, str] | None = None) -> bool: + return _env_values(env).get(_IDENTITY_HANDLE_ENV_VAR) is not None + + def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519PrivateKey | None: raw_key = _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) if raw_key is None: @@ -177,13 +181,34 @@ def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519Pr return private_key_from_hex(raw_key) +def _has_env_identity_private_key(env: Mapping[str, str] | None = None) -> bool: + return _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) is not None + + +def _validate_env_identity_configuration(env: Mapping[str, str] | None = None) -> None: + has_handle = _has_env_identity_handle(env) + has_private_key = _has_env_identity_private_key(env) + if has_handle and not has_private_key: + handle = _env_identity_handle(env) + raise ValueError( + f"{_IDENTITY_HANDLE_ENV_VAR} is set to '{handle}' but {_IDENTITY_PRIVATE_KEY_ENV_VAR} is not set. " + f"Unset {_IDENTITY_HANDLE_ENV_VAR} to use local identity files, or set both environment variables " + "to use an environment-backed identity." + ) + if has_private_key and not has_handle: + raise ValueError( + f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is set but {_IDENTITY_HANDLE_ENV_VAR} is not set. " + f"Unset {_IDENTITY_PRIVATE_KEY_ENV_VAR} to use local identity files, or set both environment variables " + "to use an environment-backed identity." + ) + + def _load_env_identity(env: Mapping[str, str] | None = None) -> IdentityMetadata | None: + _validate_env_identity_configuration(env) handle = _env_identity_handle(env) private_key = _env_identity_private_key(env) if private_key is None: return None - if handle is None: - raise ValueError(f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} requires {_IDENTITY_HANDLE_ENV_VAR} to also be set.") now = datetime.now(UTC) return IdentityMetadata( @@ -199,11 +224,6 @@ def _env_identity_matches(handle: str, env: Mapping[str, str] | None = None) -> return env_identity is not None and env_identity.handle == handle -def _has_env_identity_handle_without_private_key(env: Mapping[str, str] | None = None) -> bool: - handle = _env_identity_handle(env) - return handle is not None and _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) is None - - def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: env_key = _env_identity_private_key() env_handle = _env_identity_handle() @@ -329,13 +349,6 @@ def ensure_local_identity(home: Path, active_handle: str | None = None) -> Ident active_handle = _read_active_identity_handle(home) if active_handle: if not identity_exists(home, active_handle): - if active_handle == _env_identity_handle() and _has_env_identity_handle_without_private_key(): - raise FileNotFoundError( - f"{_IDENTITY_HANDLE_ENV_VAR} is set to '{active_handle}' but " - f"{_IDENTITY_PRIVATE_KEY_ENV_VAR} is not set, and no local identity files were found at " - f"{identities_dir(home)}. Export {_IDENTITY_PRIVATE_KEY_ENV_VAR} to use the environment-backed " - "identity, or run 'authsome init' to create and register a new local identity." - ) raise FileNotFoundError( f"Configured identity '{active_handle}' not found at {identities_dir(home)}. " "Run 'authsome init' to create and register a new identity." diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index b83b3c8e..86ca6d59 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -6,7 +6,8 @@ from authsome.cli.client import AuthsomeApiClient from authsome.cli.client_config import ClientConfig, load_client_config, save_client_config -from authsome.identity import create_identity, mark_claimed, mark_registered +from authsome.identity import create_identity, load_private_key, mark_claimed, mark_registered +from authsome.identity.local import private_key_to_hex @pytest.mark.asyncio @@ -160,11 +161,15 @@ def fake_request(method, url, data=None, headers=None, timeout=None): @pytest.mark.asyncio async def test_identity_env_override_wins_over_active_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") create_identity(tmp_path, "steady-wisely-boldly-0042") override_identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") mark_registered(tmp_path, override_identity.handle) mark_claimed(tmp_path, override_identity.handle) + monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + private_key_to_hex(load_private_key(tmp_path, override_identity.handle)), + ) save_client_config(tmp_path, ClientConfig(active_identity="steady-wisely-boldly-0042")) client = AuthsomeApiClient("http://127.0.0.1:7998") diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index 0138d111..79f4d861 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -116,12 +116,12 @@ def test_load_private_key_prefers_environment_identity_key(monkeypatch, tmp_path def test_environment_identity_key_requires_handle(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", "04" * 32) - with pytest.raises(ValueError, match="AUTHSOME_IDENTITY"): + with pytest.raises(ValueError, match="AUTHSOME_IDENTITY is not set"): ensure_local_identity(tmp_path) def test_environment_identity_handle_without_private_key_has_clear_error(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_IDENTITY", "calm-clearly-quickly-1216") - with pytest.raises(FileNotFoundError, match="AUTHSOME_IDENTITY_PRIVATE_KEY"): - ensure_local_identity(tmp_path, active_handle="calm-clearly-quickly-1216") + with pytest.raises(ValueError, match="AUTHSOME_IDENTITY_PRIVATE_KEY is not set"): + ensure_local_identity(tmp_path) From 17b0bed69958b46a855294a53d530aff29643bd6 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 21 May 2026 21:10:54 +0530 Subject: [PATCH 4/6] feat: improve local identity resolution to preserve metadata from environment-backed identities --- src/authsome/identity/local.py | 32 ++++++++++++++++++++++++++++---- tests/identity/test_identity.py | 20 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index c48afddb..46e48167 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -224,6 +224,30 @@ def _env_identity_matches(handle: str, env: Mapping[str, str] | None = None) -> return env_identity is not None and env_identity.handle == handle +def _load_local_identity_metadata(home: Path, handle: str) -> IdentityMetadata | None: + path = identity_metadata_path(home, handle) + if not path.exists(): + return None + return IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) + + +def _resolve_env_backed_identity(home: Path, handle: str) -> IdentityMetadata | None: + env_identity = _load_env_identity() + if env_identity is None or env_identity.handle != handle: + return None + + stored_metadata = _load_local_identity_metadata(home, handle) + if stored_metadata is None: + return env_identity + + return stored_metadata.model_copy( + update={ + "did": env_identity.did, + "updated_at": stored_metadata.updated_at, + } + ) + + def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: env_key = _env_identity_private_key() env_handle = _env_identity_handle() @@ -233,9 +257,9 @@ def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: def load_identity(home: Path, handle: str) -> IdentityMetadata: - env_identity = _load_env_identity() - if env_identity is not None and env_identity.handle == handle: - return env_identity + resolved_env_identity = _resolve_env_backed_identity(home, handle) + if resolved_env_identity is not None: + return resolved_env_identity return IdentityMetadata.model_validate_json(identity_metadata_path(home, handle).read_text(encoding="utf-8")) @@ -344,7 +368,7 @@ def ensure_local_identity(home: Path, active_handle: str | None = None) -> Ident f"Configured identity '{active_handle}' does not match environment identity " f"'{env_identity.handle}' from {_IDENTITY_HANDLE_ENV_VAR}." ) - return env_identity + return _resolve_env_backed_identity(home, env_identity.handle) or env_identity if active_handle is None: active_handle = _read_active_identity_handle(home) if active_handle: diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index 79f4d861..af17d9bf 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -12,6 +12,7 @@ load_private_key, mark_claimed, mark_registered, + private_key_to_hex, public_key_from_did_key, public_key_to_did_key, ) @@ -101,6 +102,25 @@ def test_ensure_local_identity_prefers_environment_identity(monkeypatch, tmp_pat assert identity.identity_status == IdentityStatus.UNREGISTERED +def test_ensure_local_identity_preserves_local_status_for_matching_env_identity( + monkeypatch, tmp_path: Path +) -> None: + identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") + mark_registered(tmp_path, identity.handle) + mark_claimed(tmp_path, identity.handle) + monkeypatch.setenv("AUTHSOME_IDENTITY", identity.handle) + monkeypatch.setenv( + "AUTHSOME_IDENTITY_PRIVATE_KEY", + private_key_to_hex(load_private_key(tmp_path, identity.handle)), + ) + + resolved = ensure_local_identity(tmp_path) + + assert resolved.handle == identity.handle + assert resolved.claimed is True + assert resolved.registered is True + + def test_load_private_key_prefers_environment_identity_key(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") monkeypatch.setenv( From d4522299414c869207ed7b489b379dba7ae8a77e Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 21 May 2026 21:13:01 +0530 Subject: [PATCH 5/6] fix: add null check for identity handle in local identity resolution and clean up test formatting --- src/authsome/identity/local.py | 2 +- tests/identity/test_identity.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index 46e48167..a15ee703 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -207,7 +207,7 @@ def _load_env_identity(env: Mapping[str, str] | None = None) -> IdentityMetadata _validate_env_identity_configuration(env) handle = _env_identity_handle(env) private_key = _env_identity_private_key(env) - if private_key is None: + if private_key is None or handle is None: return None now = datetime.now(UTC) diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index af17d9bf..92642736 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -102,9 +102,7 @@ def test_ensure_local_identity_prefers_environment_identity(monkeypatch, tmp_pat assert identity.identity_status == IdentityStatus.UNREGISTERED -def test_ensure_local_identity_preserves_local_status_for_matching_env_identity( - monkeypatch, tmp_path: Path -) -> None: +def test_ensure_local_identity_preserves_local_status_for_matching_env_identity(monkeypatch, tmp_path: Path) -> None: identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") mark_registered(tmp_path, identity.handle) mark_claimed(tmp_path, identity.handle) From d4d3fcad2e69ba54140095b58e3751311c134c24 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 25 May 2026 15:02:10 +0530 Subject: [PATCH 6/6] fix: simplify env-backed identity loading to match master-key precedence --- src/authsome/identity/local.py | 81 ++++++++++++-------------------- tests/cli/test_client_signing.py | 6 +-- tests/identity/test_identity.py | 6 +-- 3 files changed, 35 insertions(+), 58 deletions(-) diff --git a/src/authsome/identity/local.py b/src/authsome/identity/local.py index a15ee703..3ff4a9c4 100644 --- a/src/authsome/identity/local.py +++ b/src/authsome/identity/local.py @@ -168,10 +168,6 @@ def _env_identity_handle(env: Mapping[str, str] | None = None) -> str | None: return validate_handle(handle) -def _has_env_identity_handle(env: Mapping[str, str] | None = None) -> bool: - return _env_values(env).get(_IDENTITY_HANDLE_ENV_VAR) is not None - - def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519PrivateKey | None: raw_key = _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) if raw_key is None: @@ -181,13 +177,10 @@ def _env_identity_private_key(env: Mapping[str, str] | None = None) -> Ed25519Pr return private_key_from_hex(raw_key) -def _has_env_identity_private_key(env: Mapping[str, str] | None = None) -> bool: - return _env_values(env).get(_IDENTITY_PRIVATE_KEY_ENV_VAR) is not None - - def _validate_env_identity_configuration(env: Mapping[str, str] | None = None) -> None: - has_handle = _has_env_identity_handle(env) - has_private_key = _has_env_identity_private_key(env) + values = _env_values(env) + has_handle = _IDENTITY_HANDLE_ENV_VAR in values + has_private_key = _IDENTITY_PRIVATE_KEY_ENV_VAR in values if has_handle and not has_private_key: handle = _env_identity_handle(env) raise ValueError( @@ -219,52 +212,40 @@ def _load_env_identity(env: Mapping[str, str] | None = None) -> IdentityMetadata ) -def _env_identity_matches(handle: str, env: Mapping[str, str] | None = None) -> bool: +def _load_matching_env_identity(handle: str, env: Mapping[str, str] | None = None) -> IdentityMetadata | None: env_identity = _load_env_identity(env) - return env_identity is not None and env_identity.handle == handle - - -def _load_local_identity_metadata(home: Path, handle: str) -> IdentityMetadata | None: - path = identity_metadata_path(home, handle) - if not path.exists(): - return None - return IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) - - -def _resolve_env_backed_identity(home: Path, handle: str) -> IdentityMetadata | None: - env_identity = _load_env_identity() if env_identity is None or env_identity.handle != handle: return None - - stored_metadata = _load_local_identity_metadata(home, handle) - if stored_metadata is None: - return env_identity - - return stored_metadata.model_copy( - update={ - "did": env_identity.did, - "updated_at": stored_metadata.updated_at, - } - ) + return env_identity def load_private_key(home: Path, handle: str) -> Ed25519PrivateKey: - env_key = _env_identity_private_key() - env_handle = _env_identity_handle() - if env_key is not None and env_handle == handle: - return env_key + env_identity = _load_matching_env_identity(handle) + if env_identity is not None: + env_key = _env_identity_private_key() + if env_key is not None: + return env_key return private_key_from_hex(identity_key_path(home, handle).read_text(encoding="utf-8")) def load_identity(home: Path, handle: str) -> IdentityMetadata: - resolved_env_identity = _resolve_env_backed_identity(home, handle) - if resolved_env_identity is not None: - return resolved_env_identity - return IdentityMetadata.model_validate_json(identity_metadata_path(home, handle).read_text(encoding="utf-8")) + path = identity_metadata_path(home, handle) + if path.exists(): + metadata = IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) + env_identity = _load_matching_env_identity(handle) + if env_identity is None: + return metadata + return metadata.model_copy(update={"did": env_identity.did}) + + env_identity = _load_matching_env_identity(handle) + if env_identity is not None: + return env_identity + + return IdentityMetadata.model_validate_json(path.read_text(encoding="utf-8")) def identity_exists(home: Path, handle: str) -> bool: - if _env_identity_matches(handle): + if _load_matching_env_identity(handle) is not None: return True return identity_metadata_path(home, handle).exists() and identity_key_path(home, handle).exists() @@ -342,9 +323,9 @@ def mark_registered(home: Path, handle: str) -> IdentityMetadata: updated = metadata.model_copy( update={"identity_status": IdentityStatus.REGISTERED, "updated_at": datetime.now(UTC)} ) - if _env_identity_matches(handle): - return updated - identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") + metadata_path = identity_metadata_path(home, handle) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated @@ -352,9 +333,9 @@ def mark_claimed(home: Path, handle: str) -> IdentityMetadata: """Persist a claimed state for a local identity after ownership resolution.""" metadata = load_identity(home, handle) updated = metadata.model_copy(update={"identity_status": IdentityStatus.CLAIMED, "updated_at": datetime.now(UTC)}) - if _env_identity_matches(handle): - return updated - identity_metadata_path(home, handle).write_text(updated.model_dump_json(indent=2), encoding="utf-8") + metadata_path = identity_metadata_path(home, handle) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text(updated.model_dump_json(indent=2), encoding="utf-8") return updated @@ -368,7 +349,7 @@ def ensure_local_identity(home: Path, active_handle: str | None = None) -> Ident f"Configured identity '{active_handle}' does not match environment identity " f"'{env_identity.handle}' from {_IDENTITY_HANDLE_ENV_VAR}." ) - return _resolve_env_backed_identity(home, env_identity.handle) or env_identity + return load_identity(home, env_identity.handle) if active_handle is None: active_handle = _read_active_identity_handle(home) if active_handle: diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index 86ca6d59..013f9db6 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -165,11 +165,9 @@ async def test_identity_env_override_wins_over_active_identity(monkeypatch, tmp_ override_identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") mark_registered(tmp_path, override_identity.handle) mark_claimed(tmp_path, override_identity.handle) + private_key_hex = private_key_to_hex(load_private_key(tmp_path, override_identity.handle)) monkeypatch.setenv("AUTHSOME_IDENTITY", "rapid-brightly-firmly-0007") - monkeypatch.setenv( - "AUTHSOME_IDENTITY_PRIVATE_KEY", - private_key_to_hex(load_private_key(tmp_path, override_identity.handle)), - ) + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) save_client_config(tmp_path, ClientConfig(active_identity="steady-wisely-boldly-0042")) client = AuthsomeApiClient("http://127.0.0.1:7998") diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index 92642736..aff051c7 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -106,11 +106,9 @@ def test_ensure_local_identity_preserves_local_status_for_matching_env_identity( identity = create_identity(tmp_path, "rapid-brightly-firmly-0007") mark_registered(tmp_path, identity.handle) mark_claimed(tmp_path, identity.handle) + private_key_hex = private_key_to_hex(load_private_key(tmp_path, identity.handle)) monkeypatch.setenv("AUTHSOME_IDENTITY", identity.handle) - monkeypatch.setenv( - "AUTHSOME_IDENTITY_PRIVATE_KEY", - private_key_to_hex(load_private_key(tmp_path, identity.handle)), - ) + monkeypatch.setenv("AUTHSOME_IDENTITY_PRIVATE_KEY", private_key_hex) resolved = ensure_local_identity(tmp_path)