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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7eaaffb63f
commit
c6bc28442a
@@ -1093,7 +1093,7 @@ def print_command_help(command):
|
|||||||
),
|
),
|
||||||
"auth": (
|
"auth": (
|
||||||
"Usage: nap auth [status|login|repair]\n"
|
"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"
|
" status Show credential state without credential values.\n"
|
||||||
" repair Recover failed automatic credential renewal.\n"
|
" repair Recover failed automatic credential renewal.\n"
|
||||||
" logout [--project] Revoke the selected login.\n"
|
" logout [--project] Revoke the selected login.\n"
|
||||||
|
|||||||
@@ -8253,6 +8253,8 @@ def _renew_auth_locked():
|
|||||||
}, token_required=False)
|
}, token_required=False)
|
||||||
except SafeToolError as exc:
|
except SafeToolError as exc:
|
||||||
if exc.status in {400, 401}:
|
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 SafeToolError("auth_required", "The login grant is expired or revoked. Run `nap auth login`. Existing state was preserved.", status=401) from None
|
||||||
raise
|
raise
|
||||||
updates = validated_oauth_credentials(refreshed)
|
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):
|
def operator_account_login(args=None):
|
||||||
args = args or {}
|
args = args or {}
|
||||||
if args.get("project"):
|
if args.get("project"):
|
||||||
@@ -8614,11 +8668,31 @@ def operator_account_login(args=None):
|
|||||||
"scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE})
|
"scope": args.get("scope") or OPERATOR_ACCOUNT_OAUTH_SCOPE})
|
||||||
credentials = validated_oauth_credentials(result)
|
credentials = validated_oauth_credentials(result)
|
||||||
target = credential_store().login(credentials)
|
target = credential_store().login(credentials)
|
||||||
try: refresh_public_auth_state()
|
response = {"status": "authenticated", "mode": "operator_account", "auth_path": str(target),
|
||||||
except (CredentialError, SafeToolError): pass # Account login succeeded; a repository override still needs repair.
|
"base_url": credentials["base_url"], "token_expires_at": credentials["token_expires_at"]}
|
||||||
return {"status": "authenticated", "mode": "operator_account", "auth_path": str(target),
|
try:
|
||||||
"base_url": credentials["base_url"], "token_expires_at": credentials["token_expires_at"],
|
refresh_public_auth_state()
|
||||||
"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."}
|
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()
|
try: refresh_public_auth_state()
|
||||||
except CredentialError:
|
except CredentialError:
|
||||||
if not args.get("project_login"): raise
|
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")
|
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"):
|
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}")
|
project = request_json("GET", f"/v1/projects/{selected_id}")
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user