diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 75ec460..7b4a3e6 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -6591,6 +6591,56 @@ def sign_with_auth_key(namespace, text): return sign_text(namespace, pathlib.Path(AUTH["ssh_key_path"]).expanduser(), text) +def renew_anonymous_auth_with_ssh(): + """Recover an anonymous worker using its durable local signing key.""" + public_key = auth_public_key() + challenge = request_json( + "POST", + "/v1/enrollment/challenges", + { + "public_key": public_key, + "worker_name": AUTH["worker_name"], + "device_fingerprint": AUTH["device_fingerprint"], + "root_path": AUTH["root_path"], + "worker_capabilities": AUTH["worker_capabilities"], + }, + token_required=False, + ) + signature = sign_with_auth_key("napseer", challenge["challenge_text"]) + verified = request_json( + "POST", + "/v1/enrollment/verify", + {"challenge_id": challenge["challenge_id"], "signature": signature}, + token_required=False, + ) + worker = verified["worker"] + token = verified["token"] + save_auth( + { + "base_url": BASE_URL, + "token": token["access_token"], + "refresh_token": token.get("refresh_token"), + "token_expires_at": token.get("expires_at"), + "refresh_expires_at": token.get("refresh_expires_at"), + "project_id": worker.get("project_id") or DEFAULT_PROJECT_ID, + "worker_id": worker["id"], + "agent_id": worker["agent_id"], + "worker_name": worker["name"], + "device_fingerprint": worker["device_fingerprint"], + "root_path": worker["root_path"], + } + ) + return { + "status": "renewed", + "method": "anonymous_ssh_recovery", + "token_expires_at": token.get("expires_at"), + "refresh_expires_at": token.get("refresh_expires_at"), + "worker_id": worker["id"], + "agent_id": worker["agent_id"], + "project_id": worker.get("project_id") or DEFAULT_PROJECT_ID, + } + + def renew_auth(): oauth_refresh_error = None if AUTH.get("account_mode") in {"operator_account", "operator_project", "claimed"} and REFRESH_TOKEN: @@ -6640,6 +6690,8 @@ def renew_auth(): f"OAuth refresh failed ({oauth_refresh_error}); enrollment fallback also failed " f"({enrollment_refresh_error})" ) from enrollment_refresh_error + if AUTH.get("account_mode") in {None, "anonymous"}: + return renew_anonymous_auth_with_ssh() raise token = refreshed["token"] worker = refreshed.get("worker") or {} @@ -6668,48 +6720,7 @@ def renew_auth(): "project_id": DEFAULT_PROJECT_ID, } - public_key = auth_public_key() - challenge = request_json( - "POST", - "/v1/enrollment/challenges", - { - "public_key": public_key, - "worker_name": AUTH["worker_name"], - "device_fingerprint": AUTH["device_fingerprint"], - "root_path": AUTH["root_path"], - "worker_capabilities": AUTH["worker_capabilities"], - }, - token_required=False, - ) - signature = sign_with_auth_key("napseer", challenge["challenge_text"]) - verified = request_json( - "POST", - "/v1/enrollment/verify", - {"challenge_id": challenge["challenge_id"], "signature": signature}, - token_required=False, - ) - save_auth( - { - "base_url": BASE_URL, - "token": verified["token"]["access_token"], - "refresh_token": verified["token"].get("refresh_token"), - "token_expires_at": verified["token"].get("expires_at"), - "refresh_expires_at": verified["token"].get("refresh_expires_at"), - "worker_id": verified["worker"]["id"], - "agent_id": verified["worker"]["agent_id"], - "worker_name": verified["worker"]["name"], - "device_fingerprint": verified["worker"]["device_fingerprint"], - "root_path": verified["worker"]["root_path"], - } - ) - return { - "status": "renewed", - "method": "legacy_ssh_enrollment", - "token_expires_at": verified["token"].get("expires_at"), - "refresh_expires_at": verified["token"].get("refresh_expires_at"), - "worker_id": verified["worker"]["id"], - "agent_id": verified["worker"]["agent_id"], - } + return renew_anonymous_auth_with_ssh() def jwt_claims(token): @@ -7038,7 +7049,9 @@ def ensure_enrolled(args=None): { "base_url": BASE_URL, "token": verified["token"]["access_token"], + "refresh_token": verified["token"].get("refresh_token"), "token_expires_at": verified["token"].get("expires_at"), + "refresh_expires_at": verified["token"].get("refresh_expires_at"), "worker_id": verified["worker"]["id"], "agent_id": verified["worker"]["agent_id"], "worker_name": worker_name, @@ -7050,7 +7063,11 @@ def ensure_enrolled(args=None): "account_mode": AUTH.get("account_mode") or "anonymous", } ) - return {"status": "enrolled", "token_expires_at": verified["token"].get("expires_at")} + return { + "status": "enrolled", + "token_expires_at": verified["token"].get("expires_at"), + "refresh_expires_at": verified["token"].get("refresh_expires_at"), + } def gateway_service_preregister(args=None): diff --git a/tests/test_anonymous_auth_recovery.py b/tests/test_anonymous_auth_recovery.py new file mode 100644 index 0000000..82bc057 --- /dev/null +++ b/tests/test_anonymous_auth_recovery.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Focused tests for anonymous credential persistence and recovery.""" + +import importlib.util +import pathlib +import sys +import tempfile + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + sys.path.insert(0, str(script_path.parent)) + spec = importlib.util.spec_from_file_location("napseer_mcp_server_anonymous_recovery_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def anonymous_auth(mod): + return { + "base_url": "https://api.example.test", + "account_mode": "anonymous", + "worker_name": "agent", + "device_fingerprint": "device", + "root_path": "/repo", + "worker_capabilities": {"local_mcp": True}, + "ssh_key_path": "/tmp/test-key", + } + + +def test_initial_enrollment_persists_refresh_credential(): + mod = load_module() + mod.TOKEN = None + mod.AUTH = anonymous_auth(mod) + mod.BASE_URL = mod.AUTH["base_url"] + mod.DEFAULT_PROJECT_ID = None + with tempfile.TemporaryDirectory() as directory: + key_path = pathlib.Path(directory) / "test-key" + pathlib.Path(str(key_path) + ".pub").write_text( + "ssh-ed25519 test-public-key", encoding="utf-8" + ) + mod.ensure_local_files = lambda: key_path + mod.sign_text = lambda *args: "test-signature" + saved = [] + mod.save_auth = lambda payload: saved.append(dict(payload)) + responses = iter([ + {"challenge_id": "challenge", "challenge_text": "sign-me"}, + { + "token": { + "access_token": "access", + "refresh_token": "refresh", + "expires_at": "2099-01-01T00:00:00Z", + "refresh_expires_at": "", + }, + "worker": {"id": "worker", "agent_id": "agent-id"}, + }, + ]) + mod.request_json = lambda *args, **kwargs: next(responses) + + mod.ensure_enrolled({"slug": "example"}) + + assert saved[0]["refresh_token"] == "refresh" + assert "refresh_expires_at" in saved[0] + + +def test_failed_anonymous_refresh_uses_ssh_recovery(): + mod = load_module() + mod.AUTH = anonymous_auth(mod) + mod.BASE_URL = mod.AUTH["base_url"] + mod.DEFAULT_PROJECT_ID = "project" + mod.REFRESH_TOKEN = "stale-refresh" + mod.REFRESH_EXPIRES_AT = None + mod.auth_public_key = lambda: "ssh-ed25519 test-public-key" + mod.sign_with_auth_key = lambda *args: "test-signature" + saved = [] + mod.save_auth = lambda payload: saved.append(dict(payload)) + + def request(method, path, payload=None, token_required=False): + if path == "/v1/enrollment/refresh": + raise RuntimeError("HTTP 401") + if path == "/v1/enrollment/challenges": + return {"challenge_id": "challenge", "challenge_text": "sign-me"} + if path == "/v1/enrollment/verify": + return { + "token": { + "access_token": "recovered-access", + "refresh_token": "recovered-refresh", + "expires_at": "2099-01-01T00:00:00Z", + "refresh_expires_at": "", + }, + "worker": { + "id": "worker", + "agent_id": "agent-id", + "name": "agent", + "device_fingerprint": "device", + "root_path": "/repo", + "project_id": "project", + }, + } + raise AssertionError(path) + + mod.request_json = request + result = mod.renew_auth() + + assert result["method"] == "anonymous_ssh_recovery" + assert result["project_id"] == "project" + assert saved[0]["refresh_token"] == "recovered-refresh" + + +if __name__ == "__main__": + test_initial_enrollment_persists_refresh_credential() + test_failed_anonymous_refresh_uses_ssh_recovery() + print("ok: anonymous auth recovery tests passed")