From c6bc28442a8aa12e9635c3952f79180010e76515 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Mon, 28 Sep 2026 21:47:36 -0300 Subject: [PATCH] Rebind the committed project on login instead of forcing project consent In a checkout whose private project credential had been revoked, `nap auth login` refreshed only the account login while the dead project override kept being selected, and attach fell through to a second, project-scoped browser consent. Login now proves an OAuth project grant dead (tombstoned, expired, or refresh rejected; unknown outcomes keep it), selects the account login for this worktree, and attaches the project named by .napseer/project.json without another consent. A still-valid override is kept and reported. Attach takes the same fast path, and a rejected project grant names the recovery step. Co-Authored-By: Claude Opus 5.5 (1M context) --- resources/scripts/nap_install.py | 2 +- resources/scripts/napseer_mcp_server.py | 88 +++++++++++- tests/test_committed_project_login.py | 175 ++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 tests/test_committed_project_login.py diff --git a/resources/scripts/nap_install.py b/resources/scripts/nap_install.py index 67e9905..5e2cd0a 100644 --- a/resources/scripts/nap_install.py +++ b/resources/scripts/nap_install.py @@ -1093,7 +1093,7 @@ def print_command_help(command): ), "auth": ( "Usage: nap auth [status|login|repair]\n" - " login Authenticate an account; does not select a project.\n" + " login Authenticate an account and attach the project named by .napseer/project.json.\n" " status Show credential state without credential values.\n" " repair Recover failed automatic credential renewal.\n" " logout [--project] Revoke the selected login.\n" diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 53ceaf9..8fc6f16 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -8253,6 +8253,8 @@ def _renew_auth_locked(): }, token_required=False) except SafeToolError as exc: if exc.status in {400, 401}: + if AUTH.get("account_mode") == "operator_project": + raise SafeToolError("auth_required", "This checkout's project login is expired or revoked. Run `nap auth login`; it re-attaches the project from .napseer/project.json. Existing state was preserved.", status=401) from None raise SafeToolError("auth_required", "The login grant is expired or revoked. Run `nap auth login`. Existing state was preserved.", status=401) from None raise updates = validated_oauth_credentials(refreshed) @@ -8606,6 +8608,58 @@ def validated_oauth_credentials(result): } +def dead_project_override(): + """Why this checkout's project-bound OAuth grant can no longer authenticate. + + Returns None while it may still work (including on network failure), so a + live least-privilege override is never replaced. Only OAuth project grants + qualify; API keys and explicit credential files are left to their owners. + """ + from napseer_credentials import read_json + store = credential_store() + path, source = store.selected() + if source != "project": + return None + data = read_json(path) + if data.get("logged_out"): + return "logged_out" + if data.get("account_mode") != "operator_project": + return None + now = datetime.now(timezone.utc) + if recorded_auth_failure(): + return "refresh_rejected" + access_expires = parse_iso_datetime(data.get("token_expires_at")) + if data.get("token") and access_expires is not None and access_expires > now: + return None + if not data.get("refresh_token"): + return "access_expired_without_refresh" + refresh_expires = parse_iso_datetime(data.get("refresh_expires_at")) + if refresh_expires is not None and refresh_expires <= now: + return "refresh_expired" + try: + renew_auth() + except SafeToolError as exc: + return "refresh_rejected" if exc.code == "auth_required" else None + except Exception: + return None + return None + + +def user_login_available(): + from napseer_credentials import read_json + current = read_json(credential_store().user) + return bool(current.get("token")) and not current.get("logged_out") + + +def replace_dead_project_override(): + """Select the general login for this worktree when its project grant is dead.""" + reason = dead_project_override() + if reason and user_login_available(): + credential_store().use_user() + refresh_public_auth_state() + return reason + + def operator_account_login(args=None): args = args or {} if args.get("project"): @@ -8614,11 +8668,31 @@ def operator_account_login(args=None): "scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE}) credentials = validated_oauth_credentials(result) target = credential_store().login(credentials) - try: refresh_public_auth_state() - except (CredentialError, SafeToolError): pass # Account login succeeded; a repository override still needs repair. - return {"status": "authenticated", "mode": "operator_account", "auth_path": str(target), - "base_url": credentials["base_url"], "token_expires_at": credentials["token_expires_at"], - "message": "Account login stored for this user. Run `nap project attach` for an existing project or `nap project create` for a new one. Explicit project credentials continue to take precedence."} + response = {"status": "authenticated", "mode": "operator_account", "auth_path": str(target), + "base_url": credentials["base_url"], "token_expires_at": credentials["token_expires_at"]} + try: + refresh_public_auth_state() + except (CredentialError, SafeToolError): + # Account login succeeded; a broken repository override needs repair. + return {**response, "message": "Account login stored for this user. This checkout's credentials could not be read; run `nap auth repair`."} + locator = load_project_locator() + if not locator: + return {**response, "message": "Account login stored for this user. Run `nap project attach` for an existing project or `nap project create` for a new one."} + # A committed locator already names the project: rebind this checkout + # without a second, project-scoped consent. + replaced = replace_dead_project_override() + if credential_store().selected()[1] == "project": + return {**response, "credential_source": "project", + "message": "Account login stored. This checkout keeps its still-valid project credential; run `nap auth use-user` to use the account login here instead."} + try: + attached = operator_project_attach({}) + except (SafeToolError, RuntimeError) as exc: + return {**response, "credential_source": "user", "replaced_project_credential": replaced, + "project_attach": {"status": "failed", "error": safe_exception_payload(exc)}, + "message": f"Account login stored, but project {locator['project_slug']} from .napseer/project.json could not be selected. Check access, then run `nap project attach`."} + return {**response, "credential_source": "user", "replaced_project_credential": replaced, + "project_id": attached["project_id"], + "message": f"Account login stored and this checkout is attached to {locator['project_slug']} from .napseer/project.json."} @@ -8706,6 +8780,10 @@ def operator_project_attach(args=None): try: refresh_public_auth_state() except CredentialError: if not args.get("project_login"): raise + if not args.get("project_login"): + # A dead project grant must not force a browser consent when a valid + # account login can select the committed project directly. + replace_dead_project_override() selected_id = args.get("project_id") or (locator or {}).get("project_id") if selected_id and TOKEN and AUTH.get("account_mode") == "operator_account" and not args.get("project_login"): project = request_json("GET", f"/v1/projects/{selected_id}") diff --git a/tests/test_committed_project_login.py b/tests/test_committed_project_login.py new file mode 100644 index 0000000..575e159 --- /dev/null +++ b/tests/test_committed_project_login.py @@ -0,0 +1,175 @@ +"""Login in a checkout with a committed locator rebinds its project without a +second, project-scoped consent, and never replaces a still-valid override.""" +import importlib.util +import json +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "resources/scripts/napseer_mcp_server.py" +sys.path.insert(0, str(SCRIPT.parent)) +from napseer_credentials import CredentialStore, read_json # noqa: E402 + +PROJECT_ID = "33333333-3333-3333-3333-333333333333" +PROJECT = {"id": PROJECT_ID, "slug": "committed-project", "name": "Committed Project", + "encryption_state": "standard"} + + +@pytest.fixture +def provider(): + state = {"refresh": [], "project_reads": [], "token_status": 401} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def reply(self, status, value): + data = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == f"/v1/projects/{PROJECT_ID}": + state["project_reads"].append(self.headers.get("Authorization")) + if self.headers.get("Authorization") == "Bearer account-access": + return self.reply(200, PROJECT) + self.reply(401, {"error": {"code": "unauthorized"}}) + + def do_POST(self): + form = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode()) + assert self.path == "/v1/oauth/token" + state["refresh"].append(form.get("refresh_token")) + if state["token_status"] == 503: + return self.reply(503, {"error": {"code": "unavailable"}}) + self.reply(401, {"error": {"code": "invalid_grant"}}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", state + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def checkout(tmp_path, monkeypatch, url, project_credential): + root = tmp_path / "checkout" + (root / ".napseer").mkdir(parents=True) + (root / ".napseer/project.json").write_text(json.dumps({ + "schema": "napseer.project.v1", "base_url": url, + "project_id": PROJECT_ID, "project_slug": PROJECT["slug"]}) + "\n") + monkeypatch.setenv("NAPSEER_PROJECT_ROOT", str(root)) + monkeypatch.setenv("NAPSEER_BASE_URL", url) + monkeypatch.chdir(root) + spec = importlib.util.spec_from_file_location(f"committed_login_{id(root)}", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + store = module.credential_store() + if project_credential is not None: + store.login({"base_url": url, "account_mode": "operator_project", "oauth_client_id": "nap-cli", + "project_id": PROJECT_ID, **project_credential}, project=True) + oauth_calls = [] + + def consent(args): + oauth_calls.append(args["flow"]) + assert args["flow"] == "operator_login", "a committed project must not need project consent" + return {"api_base_url": url, "access_token": "account-access", "refresh_token": "account-refresh", + "expires_in": 3600, "scope": args["scope"]} + + module.oauth_loopback_authorize = consent + return module, store, oauth_calls + + +def test_login_replaces_revoked_project_grant_and_attaches_committed_project(tmp_path, monkeypatch, provider): + url, state = provider + module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, { + "token": "dead-access", "refresh_token": "dead-refresh", + "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) + + result = module.operator_account_login({"base_url": url}) + + assert oauth_calls == ["operator_login"] + assert state["refresh"] == [["dead-refresh"]], "the dead grant is proven dead before it is replaced" + assert result["replaced_project_credential"] == "refresh_rejected" + assert result["credential_source"] == "user" + assert result["project_id"] == PROJECT_ID + assert store.selected()[1] == "user" + assert read_json(store.selection)["project_id"] == PROJECT_ID + assert state["project_reads"][-1] == "Bearer account-access" + assert "dead-refresh" not in json.dumps(result) + + +def test_login_keeps_valid_project_override(tmp_path, monkeypatch, provider): + url, state = provider + module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, { + "token": "live-access", "refresh_token": "live-refresh", + "token_expires_at": "2099-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) + + result = module.operator_account_login({"base_url": url}) + + assert oauth_calls == ["operator_login"] + assert result["credential_source"] == "project" + assert "use-user" in result["message"] + assert store.selected()[1] == "project" + assert state["refresh"] == [] and state["project_reads"] == [] + + +def test_login_does_not_replace_override_when_refresh_outcome_is_unknown(tmp_path, monkeypatch, provider): + url, state = provider + state["token_status"] = 503 + module, store, _calls = checkout(tmp_path, monkeypatch, url, { + "token": "dead-access", "refresh_token": "dead-refresh", + "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) + + result = module.operator_account_login({"base_url": url}) + + assert result["credential_source"] == "project" + assert store.selected()[1] == "project" + + +def test_login_without_override_attaches_committed_project(tmp_path, monkeypatch, provider): + url, state = provider + module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None) + + result = module.operator_account_login({"base_url": url}) + + assert oauth_calls == ["operator_login"] + assert result["project_id"] == PROJECT_ID + assert read_json(store.selection)["project_id"] == PROJECT_ID + + +def test_attach_uses_account_login_when_project_grant_was_logged_out(tmp_path, monkeypatch, provider): + url, state = provider + module, store, oauth_calls = checkout(tmp_path, monkeypatch, url, None) + store.login({"base_url": url, "token": "account-access", "refresh_token": "account-refresh", + "account_mode": "operator_account", "oauth_client_id": "nap-cli", + "token_expires_at": "2099-01-01T00:00:00Z"}) + store.login({"base_url": url, "logged_out": True}, project=True) + + result = module.operator_project_attach({}) + + assert oauth_calls == [], "no browser consent is needed for a committed project" + assert result["mode"] == "operator_account" + assert result["project_id"] == PROJECT_ID + assert store.selected()[1] == "user" + + +def test_project_grant_rejection_explains_reattach(tmp_path, monkeypatch, provider): + url, _state = provider + module, _store, _calls = checkout(tmp_path, monkeypatch, url, { + "token": "dead-access", "refresh_token": "dead-refresh", + "token_expires_at": "2020-01-01T00:00:00Z", "refresh_expires_at": "2099-01-01T00:00:00Z"}) + module.refresh_public_auth_state() + with pytest.raises(module.SafeToolError) as error: + module.renew_auth() + assert "project login" in error.value.safe_message + assert ".napseer/project.json" in error.value.safe_message