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": (
|
||||
"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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user