diff --git a/README.md b/README.md index 8ad45d1..83c00eb 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,29 @@ conn ai conn run @office "uptime" ``` +### πŸ”‘ SSO / OIDC Provider Management +In remote mode, `connpy` supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the `conn sso` command suite: + +- **List configured providers**: + ```bash + conn sso --list + ``` +- **Show provider details** (sensitive credentials like secrets are masked): + ```bash + conn sso --show + ``` +- **Add or update a provider** (opens an interactive configuration wizard): + ```bash + conn sso --add + ``` +- **Delete a provider**: + ```bash + conn sso --del + ``` + +#### Security Recommendation (Secret Reference Env Vars) +To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a `$` instead of the literal secret during the `conn sso --add` prompts (e.g., `$CONN_SSO_MYPROVIDER_SECRET`). The backend gRPC server will dynamically resolve the value from its environment variables at runtime. + --- ## πŸ”Œ Plugin System diff --git a/connpy/__init__.py b/connpy/__init__.py index e7c5802..da830bf 100644 --- a/connpy/__init__.py +++ b/connpy/__init__.py @@ -106,6 +106,29 @@ conn ai conn run @office "uptime" ``` +### πŸ”‘ SSO / OIDC Provider Management +In remote mode, `connpy` supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the `conn sso` command suite: + +- **List configured providers**: + ```bash + conn sso --list + ``` +- **Show provider details** (sensitive credentials like secrets are masked): + ```bash + conn sso --show + ``` +- **Add or update a provider** (opens an interactive configuration wizard): + ```bash + conn sso --add + ``` +- **Delete a provider**: + ```bash + conn sso --del + ``` + +#### Security Recommendation (Secret Reference Env Vars) +To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a `$` instead of the literal secret during the `conn sso --add` prompts (e.g., `$CONN_SSO_MYPROVIDER_SECRET`). The backend gRPC server will dynamically resolve the value from its environment variables at runtime. + --- ## Plugin Requirements for Connpy diff --git a/connpy/_version.py b/connpy/_version.py index 7f229cf..58e5740 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.0.2" +__version__ = "6.0.3" diff --git a/connpy/cli/__init__.py b/connpy/cli/__init__.py index 0bcae70..5ee2de6 100644 --- a/connpy/cli/__init__.py +++ b/connpy/cli/__init__.py @@ -7,4 +7,5 @@ from .api_handler import APIHandler from .plugin_handler import PluginHandler from .import_export_handler import ImportExportHandler from .context_handler import ContextHandler +from .sso_handler import SSOHandler diff --git a/connpy/cli/sso_handler.py b/connpy/cli/sso_handler.py new file mode 100644 index 0000000..f8b63ac --- /dev/null +++ b/connpy/cli/sso_handler.py @@ -0,0 +1,162 @@ +import sys +import yaml +import inquirer +from .. import printer + +class SSOHandler: + def __init__(self, app): + self.app = app + + def dispatch(self, args): + if self.app.services.mode == "remote": + printer.error("SSO management commands are only available in local/server-side mode.") + sys.exit(1) + + # Parse actions from argparse mutually exclusive options + if getattr(args, "add", None): + args.action = "add" + args.provider = args.add[0] + elif getattr(args, "delete", None): + args.action = "del" + args.provider = args.delete[0] + elif getattr(args, "list", False): + args.action = "list" + elif getattr(args, "show", None): + args.action = "show" + args.provider = args.show[0] + + action = getattr(args, "action", None) + + if action == "add": + return self.add_provider(args) + elif action == "del": + return self.delete_provider(args) + elif action == "list": + return self.list_providers(args) + elif action == "show": + return self.show_provider(args) + else: + printer.error(f"Unknown action: {action}") + sys.exit(1) + + def add_provider(self, args): + provider = args.provider + sso = self.app.config.config.get("sso", {}) + providers = sso.setdefault("providers", {}) + + existing = providers.get(provider, {}) + if existing: + printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.") + + # Interactive questionnaire + questions = [ + inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")), + inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")), + inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")), + inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))), + inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", []))) + ] + + answers = inquirer.prompt(questions) + if not answers: + printer.warning("Operation cancelled.") + sys.exit(130) + + jwks_url = answers["jwks_url"].strip() + secret = answers["secret"].strip() + username_claim = answers["username_claim"].strip() + algorithms_str = answers["algorithms"].strip() + allowed_domains_str = answers.get("allowed_domains", "").strip() + + if not jwks_url and not secret: + printer.error("You must configure either a JWKS URL or a Secret.") + sys.exit(1) + + if not username_claim: + printer.error("Username claim cannot be empty.") + sys.exit(1) + + algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()] + if not algorithms: + algorithms = ["RS256"] + + allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()] + + provider_data = { + "username_claim": username_claim, + "algorithms": algorithms + } + if jwks_url: + provider_data["jwks_url"] = jwks_url + if secret: + provider_data["secret"] = secret + if allowed_domains: + provider_data["allowed_domains"] = allowed_domains + + providers[provider] = provider_data + + # Save config + try: + self.app.services.config_svc.update_setting("sso", sso) + printer.success(f"SSO Provider '{provider}' saved successfully.") + except Exception as e: + printer.error(f"Failed to save SSO configuration: {e}") + sys.exit(1) + + def delete_provider(self, args): + provider = args.provider + sso = self.app.config.config.get("sso", {}) + providers = sso.get("providers", {}) + + if provider not in providers: + printer.error(f"SSO Provider '{provider}' not found.") + sys.exit(1) + + # Confirm delete + questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)] + answers = inquirer.prompt(questions) + if not answers or not answers["confirm"]: + printer.info("Delete cancelled.") + return + + del providers[provider] + + # Save config + try: + self.app.services.config_svc.update_setting("sso", sso) + printer.success(f"SSO Provider '{provider}' deleted successfully.") + except Exception as e: + printer.error(f"Failed to save SSO configuration: {e}") + sys.exit(1) + + def list_providers(self, args): + sso = self.app.config.config.get("sso", {}) + providers = sso.get("providers", {}) + if not providers: + printer.warning("No SSO providers configured.") + return + + # Print list in YAML format + providers_list = list(providers.keys()) + yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False) + printer.data("Configured SSO Providers", yaml_str) + + def show_provider(self, args): + provider = args.provider + sso = self.app.config.config.get("sso", {}) + providers = sso.get("providers", {}) + + if provider not in providers: + printer.error(f"SSO Provider '{provider}' not found.") + sys.exit(1) + + data = providers[provider] + + # Mask client secret for display if it's sensitive and not an env var starting with $ + display_data = data.copy() + secret = display_data.get("secret") + if secret and not secret.startswith("$"): + display_data["secret"] = "********" + + yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False) + printer.data(f"SSO Provider: {provider}", yaml_str) diff --git a/connpy/completion.py b/connpy/completion.py index c41369c..e14dfe5 100755 --- a/connpy/completion.py +++ b/connpy/completion.py @@ -120,6 +120,27 @@ def _get_users(configdir): return [] +def _get_sso_providers(configdir): + import yaml + config_file = os.path.join(configdir, "config.yaml") + if not os.path.exists(config_file): + return [] + try: + with open(config_file, "r") as f: + data = yaml.safe_load(f) or {} + config_data = data.get("config", {}) + if isinstance(config_data, dict): + sso = config_data.get("sso", {}) + if isinstance(sso, dict): + providers = sso.get("providers", {}) + if isinstance(providers, dict): + return list(providers.keys()) + except Exception: + pass + return [] + + + def _build_tree(nodes, folders, profiles, plugins, configdir): """Build the declarative CLI navigation tree. @@ -236,6 +257,18 @@ def _build_tree(nodes, folders, profiles, plugins, configdir): "--help": None, "-h": None } + _sso_providers = lambda w=None: _get_sso_providers(configdir) + + sso_dict = { + "--add": {"__extra__": _sso_providers, "*": None}, + "--del": {"__extra__": _sso_providers}, + "--rm": {"__extra__": _sso_providers}, + "--show": {"__extra__": _sso_providers}, + "--list": None, + "--ls": None, + "--help": None, "-h": None + } + mv_state = {"__extra__": _nodes, "--help": None, "-h": None} cp_state = {"__extra__": _nodes, "--help": None, "-h": None} ls_state = { @@ -331,6 +364,7 @@ def _build_tree(nodes, folders, profiles, plugins, configdir): "-h": None, }, "user": user_dict, + "sso": sso_dict, "login": {"--help": None, "-h": None, "*": None}, "logout": {"--help": None, "-h": None}, "config": config_dict, diff --git a/connpy/connapp.py b/connpy/connapp.py index e8ff11e..34b66dd 100755 --- a/connpy/connapp.py +++ b/connpy/connapp.py @@ -37,7 +37,7 @@ RichHelpFormatter.group_name_formatter = str.upper from .cli import ( NodeHandler, ProfileHandler, ConfigHandler, RunHandler, AIHandler, APIHandler, PluginHandler, ImportExportHandler, - ContextHandler + ContextHandler, SSOHandler ) from .cli.helpers import nodes_completer, folders_completer, profiles_completer from .cli.help_text import get_help @@ -141,6 +141,7 @@ class connapp: from .cli.sync_handler import SyncHandler from .cli.user_handler import UserHandler from .cli.login_handler import LoginHandler + from .cli.sso_handler import SSOHandler # Instantiate Handlers self._node = NodeHandler(self) @@ -155,6 +156,7 @@ class connapp: self._sync = SyncHandler(self) self._user = UserHandler(self) self._login = LoginHandler(self) + self._sso = SSOHandler(self) # Register auto-sync hook to trigger after config saves from .configfile import configfile @@ -378,6 +380,16 @@ class connapp: userparser.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)") userparser.set_defaults(func=self._user.dispatch) + #SSOPARSER + ssoparser = subparsers.add_parser("sso", help="Manage SSO providers", description="Manage SSO providers", formatter_class=RichHelpFormatter) + ssoparser.error = self._custom_error + ssocrud = ssoparser.add_mutually_exclusive_group(required=True) + ssocrud.add_argument("--add", nargs=1, dest="add", help="Add or update SSO provider", metavar="PROVIDER_NAME") + ssocrud.add_argument("--del", "--rm", nargs=1, dest="delete", help="Delete SSO provider", metavar="PROVIDER_NAME") + ssocrud.add_argument("--list", "--ls", dest="list", action="store_true", help="List all configured SSO providers") + ssocrud.add_argument("--show", nargs=1, dest="show", help="Show SSO provider details", metavar="PROVIDER_NAME") + ssoparser.set_defaults(func=self._sso.dispatch) + #LOGINPARSER loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter) loginparser.error = self._custom_error diff --git a/connpy/grpc_layer/connpy_pb2.py b/connpy/grpc_layer/connpy_pb2.py index 3467ec8..4cd5198 100644 --- a/connpy/grpc_layer/connpy_pb2.py +++ b/connpy/grpc_layer/connpy_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onnpy.proto\x12\x06\x63onnpy\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xfc\x01\n\x0fInteractRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04sftp\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65\x62ug\x18\x03 \x01(\x08\x12\x12\n\nstdin_data\x18\x04 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x05 \x01(\x05\x12\x0c\n\x04rows\x18\x06 \x01(\x05\x12\x1e\n\x16\x63onnection_params_json\x18\x07 \x01(\t\x12\x18\n\x10\x63opilot_question\x18\x08 \x01(\t\x12\x16\n\x0e\x63opilot_action\x18\t \x01(\t\x12\x1e\n\x16\x63opilot_context_buffer\x18\n \x01(\t\x12\x1e\n\x16\x63opilot_node_info_json\x18\r \x01(\t\"\x86\x02\n\x10InteractResponse\x12\x13\n\x0bstdout_data\x18\x01 \x01(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x15\n\rerror_message\x18\x03 \x01(\t\x12\x16\n\x0e\x63opilot_prompt\x18\x04 \x01(\x08\x12\x1e\n\x16\x63opilot_buffer_preview\x18\x05 \x01(\t\x12\x1d\n\x15\x63opilot_response_json\x18\x06 \x01(\t\x12\x1e\n\x16\x63opilot_node_info_json\x18\x07 \x01(\t\x12\x1c\n\x14\x63opilot_stream_chunk\x18\x08 \x01(\t\x12 \n\x18\x63opilot_injected_command\x18\t \x01(\t\"7\n\rFilterRequest\x12\x12\n\nfilter_str\x18\x01 \x01(\t\x12\x12\n\nformat_str\x18\x02 \x01(\t\"5\n\rValueResponse\x12$\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\"\x17\n\tIdRequest\x12\n\n\x02id\x18\x01 \x01(\t\"S\n\x0bNodeRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x11\n\tis_folder\x18\x03 \x01(\x08\".\n\rDeleteRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tis_folder\x18\x02 \x01(\x08\"\x1d\n\x0cMessageValue\x12\r\n\x05value\x18\x01 \x01(\t\";\n\x0bMoveRequest\x12\x0e\n\x06src_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64st_id\x18\x02 \x01(\t\x12\x0c\n\x04\x63opy\x18\x03 \x01(\x08\"W\n\x0b\x42ulkRequest\x12\x0b\n\x03ids\x18\x01 \x03(\t\x12\r\n\x05hosts\x18\x02 \x03(\t\x12,\n\x0b\x63ommon_data\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"7\n\x0eStructResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"/\n\x0eProfileRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07resolve\x18\x02 \x01(\x08\"6\n\rStructRequest\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1e\n\rStringRequest\x12\r\n\x05value\x18\x01 \x01(\t\"\x1f\n\x0eStringResponse\x12\r\n\x05value\x18\x01 \x01(\t\"C\n\rUpdateRequest\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value\"B\n\rPluginRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\x0e\n\x06update\x18\x03 \x01(\x08\"\xa5\x01\n\nRunRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x0e\n\x06\x66older\x18\x03 \x01(\t\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x10\n\x08parallel\x18\x05 \x01(\x05\x12%\n\x04vars\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x12\x0c\n\x04name\x18\x08 \x01(\t\"\xb8\x01\n\x0bTestRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x10\n\x08\x65xpected\x18\x03 \x03(\t\x12\x0e\n\x06\x66older\x18\x04 \x01(\t\x12\x0e\n\x06prompt\x18\x05 \x01(\t\x12\x10\n\x08parallel\x18\x06 \x01(\x05\x12%\n\x04vars\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x08 \x01(\x05\x12\x0c\n\x04name\x18\t \x01(\t\"A\n\rScriptRequest\x12\x0e\n\x06param1\x18\x01 \x01(\t\x12\x0e\n\x06param2\x18\x02 \x01(\t\x12\x10\n\x08parallel\x18\x03 \x01(\x05\"3\n\rExportRequest\x12\x11\n\tfile_path\x18\x01 \x01(\t\x12\x0f\n\x07\x66olders\x18\x02 \x03(\t\"\x1c\n\x0bListRequest\x12\r\n\x05items\x18\x01 \x03(\t\"\x87\x03\n\nAskRequest\x12\x12\n\ninput_text\x18\x01 \x01(\t\x12\x0e\n\x06\x64ryrun\x18\x02 \x01(\x08\x12,\n\x0c\x63hat_history\x18\x03 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x12\n\nsession_id\x18\x04 \x01(\t\x12\r\n\x05\x64\x65\x62ug\x18\x05 \x01(\x08\x12\x16\n\x0e\x65ngineer_model\x18\x06 \x01(\t\x12\x18\n\x10\x65ngineer_api_key\x18\x07 \x01(\t\x12\x17\n\x0f\x61rchitect_model\x18\x08 \x01(\t\x12\x19\n\x11\x61rchitect_api_key\x18\t \x01(\t\x12\r\n\x05trust\x18\n \x01(\x08\x12\x1b\n\x13\x63onfirmation_answer\x18\x0b \x01(\t\x12\x11\n\tinterrupt\x18\x0c \x01(\x08\x12.\n\rengineer_auth\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0e\x61rchitect_auth\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\"\xc8\x01\n\nAIResponse\x12\x12\n\ntext_chunk\x18\x01 \x01(\t\x12\x10\n\x08is_final\x18\x02 \x01(\x08\x12,\n\x0b\x66ull_result\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x15\n\rstatus_update\x18\x04 \x01(\t\x12\x15\n\rdebug_message\x18\x05 \x01(\t\x12\x1d\n\x15requires_confirmation\x18\x06 \x01(\x08\x12\x19\n\x11important_message\x18\x07 \x01(\t\"\x1d\n\x0c\x42oolResponse\x12\r\n\x05value\x18\x01 \x01(\x08\"j\n\x0fProviderRequest\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x03 \x01(\t\x12%\n\x04\x61uth\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1b\n\nIntRequest\x12\r\n\x05value\x18\x01 \x01(\x05\"p\n\rNodeRunResult\x12\x11\n\tunique_id\x18\x01 \x01(\t\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12,\n\x0btest_result\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"m\n\x12\x46ullReplaceRequest\x12,\n\x0b\x63onnections\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\x08profiles\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"X\n\x0e\x43opilotRequest\x12\x17\n\x0fterminal_buffer\x18\x01 \x01(\t\x12\x15\n\ruser_question\x18\x02 \x01(\t\x12\x16\n\x0enode_info_json\x18\x03 \x01(\t\"U\n\x0f\x43opilotResponse\x12\x10\n\x08\x63ommands\x18\x01 \x03(\t\x12\r\n\x05guide\x18\x02 \x01(\t\x12\x12\n\nrisk_level\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"a\n\nMCPRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x17\n\x0f\x61uto_load_on_os\x18\x04 \x01(\t\x12\x0e\n\x06remove\x18\x05 \x01(\x08\"2\n\x0cLoginRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\"D\n\rLoginResponse\x12\r\n\x05token\x18\x01 \x01(\t\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x12\n\nexpires_at\x18\x03 \x01(\x03\"C\n\x15\x43hangePasswordRequest\x12\x14\n\x0cold_password\x18\x01 \x01(\t\x12\x14\n\x0cnew_password\x18\x02 \x01(\t\"I\n\x0e\x41nalyzeRequest\x12(\n\x07results\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\r\n\x05query\x18\x02 \x01(\t\":\n\x10PreflightRequest\x12\x14\n\x0ctarget_nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t2\xe1\x07\n\x0bNodeService\x12<\n\nlist_nodes\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12>\n\x0clist_folders\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x10get_node_details\x12\x11.connpy.IdRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0e\x65xplode_unique\x12\x11.connpy.IdRequest\x1a\x15.connpy.ValueResponse\"\x00\x12\x42\n\x0egenerate_cache\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x61\x64\x64_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x0bupdate_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12>\n\x0b\x64\x65lete_node\x12\x15.connpy.DeleteRequest\x1a\x16.google.protobuf.Empty\"\x00\x12:\n\tmove_node\x12\x13.connpy.MoveRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x62ulk_add\x12\x13.connpy.BulkRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\x16validate_parent_folder\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n\rinteract_node\x12\x17.connpy.InteractRequest\x1a\x18.connpy.InteractResponse\"\x00(\x01\x30\x01\x12\x44\n\x0c\x66ull_replace\x12\x1a.connpy.FullReplaceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\rget_inventory\x12\x16.google.protobuf.Empty\x1a\x1a.connpy.FullReplaceRequest\"\x00\x32\x96\x03\n\x0eProfileService\x12?\n\rlist_profiles\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x0bget_profile\x12\x16.connpy.ProfileRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0b\x61\x64\x64_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11resolve_node_data\x12\x15.connpy.StructRequest\x1a\x16.connpy.StructResponse\"\x00\x12=\n\x0e\x64\x65lete_profile\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12?\n\x0eupdate_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xae\x03\n\rConfigService\x12@\n\x0cget_settings\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x0fget_default_dir\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StringResponse\"\x00\x12\x44\n\x11set_config_folder\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x41\n\x0eupdate_setting\x12\x15.connpy.UpdateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10\x65ncrypt_password\x12\x15.connpy.StringRequest\x1a\x16.connpy.StringResponse\"\x00\x12H\n\x15\x61pply_theme_from_file\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xca\x02\n\rPluginService\x12?\n\x0clist_plugins\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12=\n\nadd_plugin\x12\x15.connpy.PluginRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\rdelete_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\renable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\x0e\x64isable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xd5\x01\n\x10\x45xecutionService\x12=\n\x0crun_commands\x12\x12.connpy.RunRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12?\n\rtest_commands\x12\x13.connpy.TestRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12\x41\n\x0erun_cli_script\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xe2\x01\n\x13ImportExportService\x12\x41\n\x0e\x65xport_to_file\x12\x15.connpy.ExportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10import_from_file\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xb5\x06\n\tAIService\x12\x33\n\x03\x61sk\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12\x38\n\x07\x63onfirm\x12\x15.connpy.StringRequest\x1a\x14.connpy.BoolResponse\"\x00\x12@\n\x0b\x61sk_copilot\x12\x16.connpy.CopilotRequest\x1a\x17.connpy.CopilotResponse\"\x00\x12@\n\rlist_sessions\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x41\n\x0e\x64\x65lete_session\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n\x12\x63onfigure_provider\x12\x17.connpy.ProviderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\rconfigure_mcp\x12\x12.connpy.MCPRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10list_mcp_servers\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x44\n\x11load_session_data\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x13\x62uild_playbook_chat\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12K\n\x19\x61nalyze_execution_results\x12\x16.connpy.AnalyzeRequest\x1a\x12.connpy.AIResponse\"\x00\x30\x01\x12M\n\x19predict_execution_results\x12\x18.connpy.PreflightRequest\x1a\x12.connpy.AIResponse\"\x00\x30\x01\x32\xc2\x02\n\rSystemService\x12\x39\n\tstart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\tdebug_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x08stop_api\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12;\n\x0brestart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x0eget_api_status\x12\x16.google.protobuf.Empty\x1a\x14.connpy.BoolResponse\"\x00\x32\x91\x01\n\x0b\x41uthService\x12\x36\n\x05login\x12\x14.connpy.LoginRequest\x1a\x15.connpy.LoginResponse\"\x00\x12J\n\x0f\x63hange_password\x12\x1d.connpy.ChangePasswordRequest\x1a\x16.google.protobuf.Empty\"\x00\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onnpy.proto\x12\x06\x63onnpy\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xfc\x01\n\x0fInteractRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04sftp\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65\x62ug\x18\x03 \x01(\x08\x12\x12\n\nstdin_data\x18\x04 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x05 \x01(\x05\x12\x0c\n\x04rows\x18\x06 \x01(\x05\x12\x1e\n\x16\x63onnection_params_json\x18\x07 \x01(\t\x12\x18\n\x10\x63opilot_question\x18\x08 \x01(\t\x12\x16\n\x0e\x63opilot_action\x18\t \x01(\t\x12\x1e\n\x16\x63opilot_context_buffer\x18\n \x01(\t\x12\x1e\n\x16\x63opilot_node_info_json\x18\r \x01(\t\"\x86\x02\n\x10InteractResponse\x12\x13\n\x0bstdout_data\x18\x01 \x01(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x15\n\rerror_message\x18\x03 \x01(\t\x12\x16\n\x0e\x63opilot_prompt\x18\x04 \x01(\x08\x12\x1e\n\x16\x63opilot_buffer_preview\x18\x05 \x01(\t\x12\x1d\n\x15\x63opilot_response_json\x18\x06 \x01(\t\x12\x1e\n\x16\x63opilot_node_info_json\x18\x07 \x01(\t\x12\x1c\n\x14\x63opilot_stream_chunk\x18\x08 \x01(\t\x12 \n\x18\x63opilot_injected_command\x18\t \x01(\t\"7\n\rFilterRequest\x12\x12\n\nfilter_str\x18\x01 \x01(\t\x12\x12\n\nformat_str\x18\x02 \x01(\t\"5\n\rValueResponse\x12$\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\"\x17\n\tIdRequest\x12\n\n\x02id\x18\x01 \x01(\t\"S\n\x0bNodeRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x11\n\tis_folder\x18\x03 \x01(\x08\".\n\rDeleteRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tis_folder\x18\x02 \x01(\x08\"\x1d\n\x0cMessageValue\x12\r\n\x05value\x18\x01 \x01(\t\";\n\x0bMoveRequest\x12\x0e\n\x06src_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64st_id\x18\x02 \x01(\t\x12\x0c\n\x04\x63opy\x18\x03 \x01(\x08\"W\n\x0b\x42ulkRequest\x12\x0b\n\x03ids\x18\x01 \x03(\t\x12\r\n\x05hosts\x18\x02 \x03(\t\x12,\n\x0b\x63ommon_data\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"7\n\x0eStructResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"/\n\x0eProfileRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07resolve\x18\x02 \x01(\x08\"6\n\rStructRequest\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1e\n\rStringRequest\x12\r\n\x05value\x18\x01 \x01(\t\"\x1f\n\x0eStringResponse\x12\r\n\x05value\x18\x01 \x01(\t\"C\n\rUpdateRequest\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value\"B\n\rPluginRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\x0e\n\x06update\x18\x03 \x01(\x08\"\xa5\x01\n\nRunRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x0e\n\x06\x66older\x18\x03 \x01(\t\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x10\n\x08parallel\x18\x05 \x01(\x05\x12%\n\x04vars\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x12\x0c\n\x04name\x18\x08 \x01(\t\"\xb8\x01\n\x0bTestRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x10\n\x08\x65xpected\x18\x03 \x03(\t\x12\x0e\n\x06\x66older\x18\x04 \x01(\t\x12\x0e\n\x06prompt\x18\x05 \x01(\t\x12\x10\n\x08parallel\x18\x06 \x01(\x05\x12%\n\x04vars\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x08 \x01(\x05\x12\x0c\n\x04name\x18\t \x01(\t\"A\n\rScriptRequest\x12\x0e\n\x06param1\x18\x01 \x01(\t\x12\x0e\n\x06param2\x18\x02 \x01(\t\x12\x10\n\x08parallel\x18\x03 \x01(\x05\"3\n\rExportRequest\x12\x11\n\tfile_path\x18\x01 \x01(\t\x12\x0f\n\x07\x66olders\x18\x02 \x03(\t\"\x1c\n\x0bListRequest\x12\r\n\x05items\x18\x01 \x03(\t\"\x87\x03\n\nAskRequest\x12\x12\n\ninput_text\x18\x01 \x01(\t\x12\x0e\n\x06\x64ryrun\x18\x02 \x01(\x08\x12,\n\x0c\x63hat_history\x18\x03 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x12\n\nsession_id\x18\x04 \x01(\t\x12\r\n\x05\x64\x65\x62ug\x18\x05 \x01(\x08\x12\x16\n\x0e\x65ngineer_model\x18\x06 \x01(\t\x12\x18\n\x10\x65ngineer_api_key\x18\x07 \x01(\t\x12\x17\n\x0f\x61rchitect_model\x18\x08 \x01(\t\x12\x19\n\x11\x61rchitect_api_key\x18\t \x01(\t\x12\r\n\x05trust\x18\n \x01(\x08\x12\x1b\n\x13\x63onfirmation_answer\x18\x0b \x01(\t\x12\x11\n\tinterrupt\x18\x0c \x01(\x08\x12.\n\rengineer_auth\x18\r \x01(\x0b\x32\x17.google.protobuf.Struct\x12/\n\x0e\x61rchitect_auth\x18\x0e \x01(\x0b\x32\x17.google.protobuf.Struct\"\xc8\x01\n\nAIResponse\x12\x12\n\ntext_chunk\x18\x01 \x01(\t\x12\x10\n\x08is_final\x18\x02 \x01(\x08\x12,\n\x0b\x66ull_result\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x15\n\rstatus_update\x18\x04 \x01(\t\x12\x15\n\rdebug_message\x18\x05 \x01(\t\x12\x1d\n\x15requires_confirmation\x18\x06 \x01(\x08\x12\x19\n\x11important_message\x18\x07 \x01(\t\"\x1d\n\x0c\x42oolResponse\x12\r\n\x05value\x18\x01 \x01(\x08\"j\n\x0fProviderRequest\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x03 \x01(\t\x12%\n\x04\x61uth\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1b\n\nIntRequest\x12\r\n\x05value\x18\x01 \x01(\x05\"p\n\rNodeRunResult\x12\x11\n\tunique_id\x18\x01 \x01(\t\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12,\n\x0btest_result\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"m\n\x12\x46ullReplaceRequest\x12,\n\x0b\x63onnections\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\x08profiles\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"X\n\x0e\x43opilotRequest\x12\x17\n\x0fterminal_buffer\x18\x01 \x01(\t\x12\x15\n\ruser_question\x18\x02 \x01(\t\x12\x16\n\x0enode_info_json\x18\x03 \x01(\t\"U\n\x0f\x43opilotResponse\x12\x10\n\x08\x63ommands\x18\x01 \x03(\t\x12\r\n\x05guide\x18\x02 \x01(\t\x12\x12\n\nrisk_level\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t\"a\n\nMCPRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x17\n\x0f\x61uto_load_on_os\x18\x04 \x01(\t\x12\x0e\n\x06remove\x18\x05 \x01(\x08\")\n\x14SSOProvidersResponse\x12\x11\n\tproviders\x18\x01 \x03(\t\"2\n\x0cLoginRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\"G\n\x0fLoginSSORequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08id_token\x18\x02 \x01(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\"D\n\rLoginResponse\x12\r\n\x05token\x18\x01 \x01(\t\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x12\n\nexpires_at\x18\x03 \x01(\x03\"C\n\x15\x43hangePasswordRequest\x12\x14\n\x0cold_password\x18\x01 \x01(\t\x12\x14\n\x0cnew_password\x18\x02 \x01(\t\"I\n\x0e\x41nalyzeRequest\x12(\n\x07results\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\r\n\x05query\x18\x02 \x01(\t\":\n\x10PreflightRequest\x12\x14\n\x0ctarget_nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t2\xe1\x07\n\x0bNodeService\x12<\n\nlist_nodes\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12>\n\x0clist_folders\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x10get_node_details\x12\x11.connpy.IdRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0e\x65xplode_unique\x12\x11.connpy.IdRequest\x1a\x15.connpy.ValueResponse\"\x00\x12\x42\n\x0egenerate_cache\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x61\x64\x64_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x0bupdate_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12>\n\x0b\x64\x65lete_node\x12\x15.connpy.DeleteRequest\x1a\x16.google.protobuf.Empty\"\x00\x12:\n\tmove_node\x12\x13.connpy.MoveRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x62ulk_add\x12\x13.connpy.BulkRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\x16validate_parent_folder\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n\rinteract_node\x12\x17.connpy.InteractRequest\x1a\x18.connpy.InteractResponse\"\x00(\x01\x30\x01\x12\x44\n\x0c\x66ull_replace\x12\x1a.connpy.FullReplaceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\rget_inventory\x12\x16.google.protobuf.Empty\x1a\x1a.connpy.FullReplaceRequest\"\x00\x32\x96\x03\n\x0eProfileService\x12?\n\rlist_profiles\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x0bget_profile\x12\x16.connpy.ProfileRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0b\x61\x64\x64_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11resolve_node_data\x12\x15.connpy.StructRequest\x1a\x16.connpy.StructResponse\"\x00\x12=\n\x0e\x64\x65lete_profile\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12?\n\x0eupdate_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xae\x03\n\rConfigService\x12@\n\x0cget_settings\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x0fget_default_dir\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StringResponse\"\x00\x12\x44\n\x11set_config_folder\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x41\n\x0eupdate_setting\x12\x15.connpy.UpdateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10\x65ncrypt_password\x12\x15.connpy.StringRequest\x1a\x16.connpy.StringResponse\"\x00\x12H\n\x15\x61pply_theme_from_file\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xca\x02\n\rPluginService\x12?\n\x0clist_plugins\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12=\n\nadd_plugin\x12\x15.connpy.PluginRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\rdelete_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\renable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\x0e\x64isable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xd5\x01\n\x10\x45xecutionService\x12=\n\x0crun_commands\x12\x12.connpy.RunRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12?\n\rtest_commands\x12\x13.connpy.TestRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12\x41\n\x0erun_cli_script\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xe2\x01\n\x13ImportExportService\x12\x41\n\x0e\x65xport_to_file\x12\x15.connpy.ExportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10import_from_file\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xb5\x06\n\tAIService\x12\x33\n\x03\x61sk\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12\x38\n\x07\x63onfirm\x12\x15.connpy.StringRequest\x1a\x14.connpy.BoolResponse\"\x00\x12@\n\x0b\x61sk_copilot\x12\x16.connpy.CopilotRequest\x1a\x17.connpy.CopilotResponse\"\x00\x12@\n\rlist_sessions\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x41\n\x0e\x64\x65lete_session\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n\x12\x63onfigure_provider\x12\x17.connpy.ProviderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\rconfigure_mcp\x12\x12.connpy.MCPRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10list_mcp_servers\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x44\n\x11load_session_data\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x13\x62uild_playbook_chat\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12K\n\x19\x61nalyze_execution_results\x12\x16.connpy.AnalyzeRequest\x1a\x12.connpy.AIResponse\"\x00\x30\x01\x12M\n\x19predict_execution_results\x12\x18.connpy.PreflightRequest\x1a\x12.connpy.AIResponse\"\x00\x30\x01\x32\xc2\x02\n\rSystemService\x12\x39\n\tstart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\tdebug_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x08stop_api\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12;\n\x0brestart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x0eget_api_status\x12\x16.google.protobuf.Empty\x1a\x14.connpy.BoolResponse\"\x00\x32\x9d\x02\n\x0b\x41uthService\x12\x36\n\x05login\x12\x14.connpy.LoginRequest\x1a\x15.connpy.LoginResponse\"\x00\x12=\n\tlogin_sso\x12\x17.connpy.LoginSSORequest\x1a\x15.connpy.LoginResponse\"\x00\x12J\n\x0f\x63hange_password\x12\x1d.connpy.ChangePasswordRequest\x1a\x16.google.protobuf.Empty\"\x00\x12K\n\x11get_sso_providers\x12\x16.google.protobuf.Empty\x1a\x1c.connpy.SSOProvidersResponse\"\x00\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -97,32 +97,36 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['_COPILOTRESPONSE']._serialized_end=3088 _globals['_MCPREQUEST']._serialized_start=3090 _globals['_MCPREQUEST']._serialized_end=3187 - _globals['_LOGINREQUEST']._serialized_start=3189 - _globals['_LOGINREQUEST']._serialized_end=3239 - _globals['_LOGINRESPONSE']._serialized_start=3241 - _globals['_LOGINRESPONSE']._serialized_end=3309 - _globals['_CHANGEPASSWORDREQUEST']._serialized_start=3311 - _globals['_CHANGEPASSWORDREQUEST']._serialized_end=3378 - _globals['_ANALYZEREQUEST']._serialized_start=3380 - _globals['_ANALYZEREQUEST']._serialized_end=3453 - _globals['_PREFLIGHTREQUEST']._serialized_start=3455 - _globals['_PREFLIGHTREQUEST']._serialized_end=3513 - _globals['_NODESERVICE']._serialized_start=3516 - _globals['_NODESERVICE']._serialized_end=4509 - _globals['_PROFILESERVICE']._serialized_start=4512 - _globals['_PROFILESERVICE']._serialized_end=4918 - _globals['_CONFIGSERVICE']._serialized_start=4921 - _globals['_CONFIGSERVICE']._serialized_end=5351 - _globals['_PLUGINSERVICE']._serialized_start=5354 - _globals['_PLUGINSERVICE']._serialized_end=5684 - _globals['_EXECUTIONSERVICE']._serialized_start=5687 - _globals['_EXECUTIONSERVICE']._serialized_end=5900 - _globals['_IMPORTEXPORTSERVICE']._serialized_start=5903 - _globals['_IMPORTEXPORTSERVICE']._serialized_end=6129 - _globals['_AISERVICE']._serialized_start=6132 - _globals['_AISERVICE']._serialized_end=6953 - _globals['_SYSTEMSERVICE']._serialized_start=6956 - _globals['_SYSTEMSERVICE']._serialized_end=7278 - _globals['_AUTHSERVICE']._serialized_start=7281 - _globals['_AUTHSERVICE']._serialized_end=7426 + _globals['_SSOPROVIDERSRESPONSE']._serialized_start=3189 + _globals['_SSOPROVIDERSRESPONSE']._serialized_end=3230 + _globals['_LOGINREQUEST']._serialized_start=3232 + _globals['_LOGINREQUEST']._serialized_end=3282 + _globals['_LOGINSSOREQUEST']._serialized_start=3284 + _globals['_LOGINSSOREQUEST']._serialized_end=3355 + _globals['_LOGINRESPONSE']._serialized_start=3357 + _globals['_LOGINRESPONSE']._serialized_end=3425 + _globals['_CHANGEPASSWORDREQUEST']._serialized_start=3427 + _globals['_CHANGEPASSWORDREQUEST']._serialized_end=3494 + _globals['_ANALYZEREQUEST']._serialized_start=3496 + _globals['_ANALYZEREQUEST']._serialized_end=3569 + _globals['_PREFLIGHTREQUEST']._serialized_start=3571 + _globals['_PREFLIGHTREQUEST']._serialized_end=3629 + _globals['_NODESERVICE']._serialized_start=3632 + _globals['_NODESERVICE']._serialized_end=4625 + _globals['_PROFILESERVICE']._serialized_start=4628 + _globals['_PROFILESERVICE']._serialized_end=5034 + _globals['_CONFIGSERVICE']._serialized_start=5037 + _globals['_CONFIGSERVICE']._serialized_end=5467 + _globals['_PLUGINSERVICE']._serialized_start=5470 + _globals['_PLUGINSERVICE']._serialized_end=5800 + _globals['_EXECUTIONSERVICE']._serialized_start=5803 + _globals['_EXECUTIONSERVICE']._serialized_end=6016 + _globals['_IMPORTEXPORTSERVICE']._serialized_start=6019 + _globals['_IMPORTEXPORTSERVICE']._serialized_end=6245 + _globals['_AISERVICE']._serialized_start=6248 + _globals['_AISERVICE']._serialized_end=7069 + _globals['_SYSTEMSERVICE']._serialized_start=7072 + _globals['_SYSTEMSERVICE']._serialized_end=7394 + _globals['_AUTHSERVICE']._serialized_start=7397 + _globals['_AUTHSERVICE']._serialized_end=7682 # @@protoc_insertion_point(module_scope) diff --git a/connpy/grpc_layer/connpy_pb2_grpc.py b/connpy/grpc_layer/connpy_pb2_grpc.py index 28f077c..9726f6f 100644 --- a/connpy/grpc_layer/connpy_pb2_grpc.py +++ b/connpy/grpc_layer/connpy_pb2_grpc.py @@ -2637,11 +2637,21 @@ class AuthServiceStub(object): request_serializer=connpy__pb2.LoginRequest.SerializeToString, response_deserializer=connpy__pb2.LoginResponse.FromString, _registered_method=True) + self.login_sso = channel.unary_unary( + '/connpy.AuthService/login_sso', + request_serializer=connpy__pb2.LoginSSORequest.SerializeToString, + response_deserializer=connpy__pb2.LoginResponse.FromString, + _registered_method=True) self.change_password = channel.unary_unary( '/connpy.AuthService/change_password', request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString, response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, _registered_method=True) + self.get_sso_providers = channel.unary_unary( + '/connpy.AuthService/get_sso_providers', + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=connpy__pb2.SSOProvidersResponse.FromString, + _registered_method=True) class AuthServiceServicer(object): @@ -2653,12 +2663,24 @@ class AuthServiceServicer(object): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def login_sso(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def change_password(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def get_sso_providers(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_AuthServiceServicer_to_server(servicer, server): rpc_method_handlers = { @@ -2667,11 +2689,21 @@ def add_AuthServiceServicer_to_server(servicer, server): request_deserializer=connpy__pb2.LoginRequest.FromString, response_serializer=connpy__pb2.LoginResponse.SerializeToString, ), + 'login_sso': grpc.unary_unary_rpc_method_handler( + servicer.login_sso, + request_deserializer=connpy__pb2.LoginSSORequest.FromString, + response_serializer=connpy__pb2.LoginResponse.SerializeToString, + ), 'change_password': grpc.unary_unary_rpc_method_handler( servicer.change_password, request_deserializer=connpy__pb2.ChangePasswordRequest.FromString, response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, ), + 'get_sso_providers': grpc.unary_unary_rpc_method_handler( + servicer.get_sso_providers, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'connpy.AuthService', rpc_method_handlers) @@ -2710,6 +2742,33 @@ class AuthService(object): metadata, _registered_method=True) + @staticmethod + def login_sso(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/connpy.AuthService/login_sso', + connpy__pb2.LoginSSORequest.SerializeToString, + connpy__pb2.LoginResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def change_password(request, target, @@ -2736,3 +2795,30 @@ class AuthService(object): timeout, metadata, _registered_method=True) + + @staticmethod + def get_sso_providers(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/connpy.AuthService/get_sso_providers', + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + connpy__pb2.SSOProvidersResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/connpy/grpc_layer/server.py b/connpy/grpc_layer/server.py index 9aa413f..8847fac 100644 --- a/connpy/grpc_layer/server.py +++ b/connpy/grpc_layer/server.py @@ -1273,7 +1273,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer): context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password") token = self.registry.user_service.generate_jwt(username) - expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp()) + expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp()) return connpy_pb2.LoginResponse( token=token, @@ -1281,6 +1281,137 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer): expires_at=expires_at ) + @handle_errors + def login_sso(self, request, context): + username = request.username + id_token = request.id_token + provider = request.provider + + if not id_token or not provider: + context.abort(grpc.StatusCode.INVALID_ARGUMENT, "id_token and provider are required") + + # Load SSO configuration + sso_config = {} + if self.registry: + shared_config = self.registry.get_shared_config() + if shared_config: + sso_config = shared_config.config.get("sso", {}) + + providers = sso_config.get("providers", {}) + if provider not in providers: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"SSO Provider '{provider}' not configured in config.yaml") + + p_config = providers[provider] + jwks_url = p_config.get("jwks_url") + secret = p_config.get("secret") + + if secret and secret.startswith("$"): + import os + secret = os.getenv(secret[1:]) + + if not jwks_url and not secret: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"Provider '{provider}' has no jwks_url or secret configured") + + # Validate token + import jwt + try: + algorithms = p_config.get("algorithms", ["RS256"] if jwks_url else ["HS256"]) + verify_aud = "audience" in p_config + audience = p_config.get("audience") + verify_iss = "issuer" in p_config + issuer = p_config.get("issuer") + + options = { + "verify_signature": True, + "verify_exp": True, + "verify_aud": verify_aud, + "verify_iss": verify_iss + } + + decode_kwargs = { + "algorithms": algorithms, + "options": options + } + if verify_aud: + decode_kwargs["audience"] = audience + if verify_iss: + decode_kwargs["issuer"] = issuer + + if jwks_url: + from jwt import PyJWKClient + jwks_client = PyJWKClient(jwks_url) + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + payload = jwt.decode(id_token, signing_key.key, **decode_kwargs) + else: + payload = jwt.decode(id_token, secret, **decode_kwargs) + + except Exception as e: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO Token validation failed: {str(e)}") + + # Extract username from claim + username_claim = p_config.get("username_claim", "sub") + claim_username = payload.get(username_claim) + if not claim_username: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Username claim '{username_claim}' not found in SSO Token") + + # Check domain restrictions (allowed_domains) + allowed_domains = p_config.get("allowed_domains", []) + if allowed_domains: + email = payload.get("email") + if not email and claim_username and "@" in claim_username: + email = claim_username + + if not email: + context.abort(grpc.StatusCode.UNAUTHENTICATED, "Domain restriction enabled but no email claim found in SSO Token") + + try: + user_domain = email.split("@")[-1].strip().lower() + except Exception: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Invalid email format in SSO Token: '{email}'") + + allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d] + if user_domain not in allowed_domains_lower: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO user domain '{user_domain}' not allowed") + + # Normalize username to alphanumeric/dashes/underscores to match connpy's username regex + import re + normalized_username = re.sub(r'[^a-zA-Z0-9_-]', '_', claim_username.split('@')[0]) + + # If a requested username was sent, verify it matches + if username and username != normalized_username: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Mismatched username. Expected '{normalized_username}', got '{username}'") + + # Check if user exists in connpy registry, otherwise auto-provision + try: + user_exists = any(u["username"] == normalized_username for u in self.registry.user_service.list_users()) + if not user_exists: + import secrets + # Provision new user with random password (never used directly) + self.registry.user_service.create_user(normalized_username, secrets.token_hex(32)) + except Exception as e: + context.abort(grpc.StatusCode.INTERNAL, f"Failed to auto-provision user: {str(e)}") + + # Generate native connpy JWT token + token = self.registry.user_service.generate_jwt(normalized_username) + expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp()) + + return connpy_pb2.LoginResponse( + token=token, + username=normalized_username, + expires_at=expires_at + ) + + @handle_errors + def get_sso_providers(self, request, context): + sso_config = {} + if self.registry: + shared_config = self.registry.get_shared_config() + if shared_config: + sso_config = shared_config.config.get("sso", {}) + providers = list(sso_config.get("providers", {}).keys()) + external_providers = [p for p in providers if p != "trusted_gateway"] + return connpy_pb2.SSOProvidersResponse(providers=external_providers) + @handle_errors def change_password(self, request, context): username = _current_user.get() @@ -1296,7 +1427,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer): return Empty() class AuthInterceptor(grpc.ServerInterceptor): - OPEN_METHODS = ["/connpy.AuthService/login"] + OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"] def __init__(self, registry): self.registry = registry @@ -1422,6 +1553,15 @@ def serve(config, port=8048, debug=False): fallback_provider = ServiceProvider(config, mode="local") registry = UserRegistry(config.defaultdir) + # Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env + import os + if os.getenv("CONN_SSO_GATEWAY_SECRET") and registry._shared_config: + sso_config = registry._shared_config.config.get("sso", {}) + providers = sso_config.get("providers", {}) + if "trusted_gateway" not in providers: + from connpy import printer + printer.warning("CONN_SSO_GATEWAY_SECRET is defined in environment, but 'trusted_gateway' is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.") + interceptors = [] if debug: interceptors.append(LoggingInterceptor()) diff --git a/connpy/grpc_layer/user_registry.py b/connpy/grpc_layer/user_registry.py index 12fc108..d4a936c 100644 --- a/connpy/grpc_layer/user_registry.py +++ b/connpy/grpc_layer/user_registry.py @@ -92,6 +92,12 @@ class UserRegistry: def has_users(self) -> bool: """Check if any users are registered (enables auth enforcement).""" return bool(self.user_service.list_users()) + + def get_shared_config(self): + """Thread-safe access to the hot-reloaded shared configuration.""" + with self._lock: + self._refresh_shared() + return self._shared_config def evict(self, username): """Remove and cleanly shut down cached provider (after delete or password change).""" diff --git a/connpy/proto/connpy.proto b/connpy/proto/connpy.proto index 842ff73..c23a32c 100644 --- a/connpy/proto/connpy.proto +++ b/connpy/proto/connpy.proto @@ -301,7 +301,13 @@ message MCPRequest { service AuthService { rpc login (LoginRequest) returns (LoginResponse) {} + rpc login_sso (LoginSSORequest) returns (LoginResponse) {} rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {} + rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {} +} + +message SSOProvidersResponse { + repeated string providers = 1; } message LoginRequest { @@ -309,6 +315,12 @@ message LoginRequest { string password = 2; } +message LoginSSORequest { + string username = 1; + string id_token = 2; + string provider = 3; +} + message LoginResponse { string token = 1; string username = 2; diff --git a/connpy/services/user_service.py b/connpy/services/user_service.py index af40322..257abcc 100644 --- a/connpy/services/user_service.py +++ b/connpy/services/user_service.py @@ -210,7 +210,7 @@ class UserService: return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8")) def generate_jwt(self, username) -> str: - """Generates a secure JSON Web Token for the user expiring in 8 hours.""" + """Generates a secure JSON Web Token for the user expiring in 12 hours.""" registry = self._load_registry() if username not in registry["users"]: raise ValueError(f"User '{username}' not found") @@ -221,7 +221,8 @@ class UserService: "exp": expiration } - token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256") + secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"] + token = jwt.encode(payload, secret, algorithm="HS256") if isinstance(token, bytes): token = token.decode("utf-8") @@ -231,7 +232,8 @@ class UserService: """Decodes JWT and returns username if token is valid and unexpired.""" registry = self._load_registry() try: - payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"]) + secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"] + payload = jwt.decode(token, secret, algorithms=["HS256"]) return payload.get("sub") except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError): return None diff --git a/connpy/tests/test_cli_sso.py b/connpy/tests/test_cli_sso.py new file mode 100644 index 0000000..6377c6d --- /dev/null +++ b/connpy/tests/test_cli_sso.py @@ -0,0 +1,67 @@ +import pytest +from unittest.mock import MagicMock, patch +from connpy.cli.sso_handler import SSOHandler + +def test_sso_handler_add_provider_with_allowed_domains(): + # 1. Setup mock app structure + app_mock = MagicMock() + app_mock.services.mode = "local" + app_mock.config.config = {"sso": {"providers": {}}} + + handler = SSOHandler(app_mock) + + # Mock inquirer prompts + mock_answers = { + "jwks_url": "https://accounts.google.com/.well-known/jwks.json", + "secret": "my-secret-key", + "username_claim": "email", + "algorithms": "RS256, HS256", + "allowed_domains": "yyy.com, company.org" + } + + args_mock = MagicMock() + args_mock.provider = "google" + + with patch("inquirer.prompt", return_value=mock_answers): + handler.add_provider(args_mock) + + # Verify update_setting was called with the correct data structure + app_mock.services.config_svc.update_setting.assert_called_once() + saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0] + + assert saved_key == "sso" + assert "providers" in saved_sso_config + assert "google" in saved_sso_config["providers"] + + google_config = saved_sso_config["providers"]["google"] + assert google_config["jwks_url"] == "https://accounts.google.com/.well-known/jwks.json" + assert google_config["secret"] == "my-secret-key" + assert google_config["username_claim"] == "email" + assert google_config["algorithms"] == ["RS256", "HS256"] + assert google_config["allowed_domains"] == ["yyy.com", "company.org"] + +def test_sso_handler_add_provider_allowed_domains_empty(): + app_mock = MagicMock() + app_mock.services.mode = "local" + app_mock.config.config = {"sso": {"providers": {}}} + + handler = SSOHandler(app_mock) + + mock_answers = { + "jwks_url": "https://accounts.google.com/.well-known/jwks.json", + "secret": "", + "username_claim": "sub", + "algorithms": "RS256", + "allowed_domains": " " # empty input + } + + args_mock = MagicMock() + args_mock.provider = "google" + + with patch("inquirer.prompt", return_value=mock_answers): + handler.add_provider(args_mock) + + saved_key, saved_sso_config = app_mock.services.config_svc.update_setting.call_args[0] + google_config = saved_sso_config["providers"]["google"] + + assert "allowed_domains" not in google_config diff --git a/connpy/tests/test_completion.py b/connpy/tests/test_completion.py index 967a481..af6c88f 100644 --- a/connpy/tests/test_completion.py +++ b/connpy/tests/test_completion.py @@ -199,4 +199,47 @@ class TestUserCompletions: assert "--help" in logout_completions +class TestSsoCompletions: + def test_sso_command_options(self): + from connpy.completion import _build_tree, resolve_completion + tree = _build_tree([], [], [], {}, "/tmp") + + # Test options at the "sso" level + sso_completions = resolve_completion(["sso", ""], tree) + assert "--add" in sso_completions + assert "--del" in sso_completions + assert "--rm" in sso_completions + assert "--show" in sso_completions + assert "--list" in sso_completions + assert "--ls" in sso_completions + + def test_sso_action_completed_providers(self, tmp_path): + from connpy.completion import _build_tree, resolve_completion + import yaml + + # Create mock config.yaml with SSO providers + config_file = tmp_path / "config.yaml" + config_data = { + "config": { + "sso": { + "providers": { + "google": {"username_claim": "email"}, + "authelia": {"username_claim": "sub"} + } + } + } + } + with open(config_file, "w") as f: + yaml.dump(config_data, f) + + tree = _build_tree([], [], [], {}, str(tmp_path)) + + # Resolve after --del, --rm, --show, --add + for action in ["--del", "--rm", "--show", "--add"]: + completions = resolve_completion(["sso", action, ""], tree) + assert "google" in completions + assert "authelia" in completions + + + diff --git a/connpy/tests/test_multiuser_grpc_auth.py b/connpy/tests/test_multiuser_grpc_auth.py index c88e60f..db64296 100644 --- a/connpy/tests/test_multiuser_grpc_auth.py +++ b/connpy/tests/test_multiuser_grpc_auth.py @@ -129,3 +129,232 @@ class TestGRPCAuthentication: # 4. Logging in with new password must succeed login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass")) assert login_res_new.token is not None + + def test_sso_login_success_and_auto_provision(self, channel, registry): + """Tests that a valid SSO token successfully logs the user in and auto-provisions their account.""" + import jwt + + # 1. Setup SSO configuration in the registry's shared config + registry._shared_config.config["sso"] = { + "providers": { + "authelia": { + "secret": "sso-shared-secret", + "username_claim": "preferred_username", + "algorithms": ["HS256"] + } + } + } + + # 2. Check that the user 'ssoalice' does not exist yet + assert not any(u["username"] == "ssoalice" for u in registry.user_service.list_users()) + + # 3. Generate a valid SSO token signed with Authelia's secret + sso_token = jwt.encode( + {"preferred_username": "ssoalice"}, + "sso-shared-secret", + algorithm="HS256" + ) + + # 4. Call login_sso + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="ssoalice", + id_token=sso_token, + provider="authelia" + ) + login_res = auth_stub.login_sso(login_req) + + assert login_res.username == "ssoalice" + assert isinstance(login_res.token, str) + assert login_res.expires_at > 0 + + # 5. Verify user 'ssoalice' was auto-created/provisioned + assert any(u["username"] == "ssoalice" for u in registry.user_service.list_users()) + + # 6. Make an authenticated call to NodeService list_nodes with the returned token + node_stub = connpy_pb2_grpc.NodeServiceStub(channel) + req = connpy_pb2.FilterRequest() + metadata = [("authorization", f"Bearer {login_res.token}")] + res = node_stub.list_nodes(req, metadata=metadata) + assert res is not None + + def test_sso_login_invalid_signature(self, channel, registry): + """Verifies that an SSO token with an invalid signature fails with UNAUTHENTICATED.""" + import jwt + + registry._shared_config.config["sso"] = { + "providers": { + "authelia": { + "secret": "sso-shared-secret", + "username_claim": "sub", + "algorithms": ["HS256"] + } + } + } + + # Token signed with a WRONG key + wrong_token = jwt.encode({"sub": "bob"}, "wrong-secret", algorithm="HS256") + + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="bob", + id_token=wrong_token, + provider="authelia" + ) + + with pytest.raises(grpc.RpcError) as exc: + auth_stub.login_sso(login_req) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + assert "SSO Token validation failed" in exc.value.details() + + def test_sso_login_mismatched_username(self, channel, registry): + """Verifies that if the requested username doesn't match the token claim, it fails.""" + import jwt + + registry._shared_config.config["sso"] = { + "providers": { + "authelia": { + "secret": "sso-shared-secret", + "username_claim": "sub", + "algorithms": ["HS256"] + } + } + } + + token = jwt.encode({"sub": "charlie"}, "sso-shared-secret", algorithm="HS256") + + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="different_user", + id_token=token, + provider="authelia" + ) + + with pytest.raises(grpc.RpcError) as exc: + auth_stub.login_sso(login_req) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + assert "Mismatched username" in exc.value.details() + + def test_sso_login_allowed_domains_success(self, channel, registry): + """Verifies that SSO login succeeds if email matches allowed_domains.""" + import jwt + registry._shared_config.config["sso"] = { + "providers": { + "google": { + "secret": "google-secret", + "username_claim": "sub", + "algorithms": ["HS256"], + "allowed_domains": ["yyy.com", "other.org"] + } + } + } + + token = jwt.encode( + {"sub": "john", "email": "john@yyy.com"}, + "google-secret", + algorithm="HS256" + ) + + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="john", + id_token=token, + provider="google" + ) + login_res = auth_stub.login_sso(login_req) + assert login_res.username == "john" + + def test_sso_login_allowed_domains_failed(self, channel, registry): + """Verifies that SSO login fails if email does not match allowed_domains.""" + import jwt + registry._shared_config.config["sso"] = { + "providers": { + "google": { + "secret": "google-secret", + "username_claim": "sub", + "algorithms": ["HS256"], + "allowed_domains": ["yyy.com"] + } + } + } + + token = jwt.encode( + {"sub": "john", "email": "john@attacker.com"}, + "google-secret", + algorithm="HS256" + ) + + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="john", + id_token=token, + provider="google" + ) + + with pytest.raises(grpc.RpcError) as exc: + auth_stub.login_sso(login_req) + assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED + assert "SSO user domain 'attacker.com' not allowed" in exc.value.details() + + def test_sso_login_allowed_domains_fallback_to_username(self, channel, registry): + """Verifies allowed_domains validation falls back to username claim if email is not present.""" + import jwt + registry._shared_config.config["sso"] = { + "providers": { + "google": { + "secret": "google-secret", + "username_claim": "sub", + "algorithms": ["HS256"], + "allowed_domains": ["yyy.com"] + } + } + } + + token = jwt.encode( + {"sub": "john@yyy.com"}, + "google-secret", + algorithm="HS256" + ) + + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_req = connpy_pb2.LoginSSORequest( + username="john", + id_token=token, + provider="google" + ) + login_res = auth_stub.login_sso(login_req) + assert login_res.username == "john" + + def test_login_and_login_sso_expiration_time(self, channel, registry): + """Verifies expires_at is set to 12 hours in both login and login_sso.""" + import jwt + import datetime + + # 1. Test standard login expiration + registry.user_service.create_user("exp_user", "password123") + auth_stub = connpy_pb2_grpc.AuthServiceStub(channel) + login_res = auth_stub.login(connpy_pb2.LoginRequest(username="exp_user", password="password123")) + + now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + expected_expires_12h = now + 12 * 3600 + # Allow a 10s buffer for execution lag + assert abs(login_res.expires_at - expected_expires_12h) < 10 + + # 2. Test SSO login expiration + registry._shared_config.config["sso"] = { + "providers": { + "authelia": { + "secret": "sso-secret", + "username_claim": "sub", + "algorithms": ["HS256"] + } + } + } + token = jwt.encode({"sub": "sso_exp_user"}, "sso-secret", algorithm="HS256") + login_sso_res = auth_stub.login_sso(connpy_pb2.LoginSSORequest( + username="sso_exp_user", + id_token=token, + provider="authelia" + )) + + assert abs(login_sso_res.expires_at - expected_expires_12h) < 10 diff --git a/docs/connpy/cli/index.html b/docs/connpy/cli/index.html index 7efb21a..24769ee 100644 --- a/docs/connpy/cli/index.html +++ b/docs/connpy/cli/index.html @@ -92,6 +92,10 @@ el.replaceWith(d);
+
connpy.cli.sso_handler
+
+
+
connpy.cli.sync_handler
@@ -142,6 +146,7 @@ el.replaceWith(d);
  • connpy.cli.plugin_handler
  • connpy.cli.profile_handler
  • connpy.cli.run_handler
  • +
  • connpy.cli.sso_handler
  • connpy.cli.sync_handler
  • connpy.cli.terminal_ui
  • connpy.cli.user_handler
  • diff --git a/docs/connpy/cli/sso_handler.html b/docs/connpy/cli/sso_handler.html new file mode 100644 index 0000000..01406bb --- /dev/null +++ b/docs/connpy/cli/sso_handler.html @@ -0,0 +1,459 @@ + + + + + + +connpy.cli.sso_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module connpy.cli.sso_handler

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SSOHandler +(app) +
    +
    +
    + +Expand source code + +
    class SSOHandler:
    +    def __init__(self, app):
    +        self.app = app
    +
    +    def dispatch(self, args):
    +        if self.app.services.mode == "remote":
    +            printer.error("SSO management commands are only available in local/server-side mode.")
    +            sys.exit(1)
    +
    +        # Parse actions from argparse mutually exclusive options
    +        if getattr(args, "add", None):
    +            args.action = "add"
    +            args.provider = args.add[0]
    +        elif getattr(args, "delete", None):
    +            args.action = "del"
    +            args.provider = args.delete[0]
    +        elif getattr(args, "list", False):
    +            args.action = "list"
    +        elif getattr(args, "show", None):
    +            args.action = "show"
    +            args.provider = args.show[0]
    +
    +        action = getattr(args, "action", None)
    +        
    +        if action == "add":
    +            return self.add_provider(args)
    +        elif action == "del":
    +            return self.delete_provider(args)
    +        elif action == "list":
    +            return self.list_providers(args)
    +        elif action == "show":
    +            return self.show_provider(args)
    +        else:
    +            printer.error(f"Unknown action: {action}")
    +            sys.exit(1)
    +
    +    def add_provider(self, args):
    +        provider = args.provider
    +        sso = self.app.config.config.get("sso", {})
    +        providers = sso.setdefault("providers", {})
    +        
    +        existing = providers.get(provider, {})
    +        if existing:
    +            printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
    +        
    +        # Interactive questionnaire
    +        questions = [
    +            inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
    +            inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
    +            inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
    +            inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
    +            inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
    +        ]
    +        
    +        answers = inquirer.prompt(questions)
    +        if not answers:
    +            printer.warning("Operation cancelled.")
    +            sys.exit(130)
    +            
    +        jwks_url = answers["jwks_url"].strip()
    +        secret = answers["secret"].strip()
    +        username_claim = answers["username_claim"].strip()
    +        algorithms_str = answers["algorithms"].strip()
    +        allowed_domains_str = answers.get("allowed_domains", "").strip()
    +        
    +        if not jwks_url and not secret:
    +            printer.error("You must configure either a JWKS URL or a Secret.")
    +            sys.exit(1)
    +            
    +        if not username_claim:
    +            printer.error("Username claim cannot be empty.")
    +            sys.exit(1)
    +            
    +        algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
    +        if not algorithms:
    +            algorithms = ["RS256"]
    +            
    +        allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
    +            
    +        provider_data = {
    +            "username_claim": username_claim,
    +            "algorithms": algorithms
    +        }
    +        if jwks_url:
    +            provider_data["jwks_url"] = jwks_url
    +        if secret:
    +            provider_data["secret"] = secret
    +        if allowed_domains:
    +            provider_data["allowed_domains"] = allowed_domains
    +            
    +        providers[provider] = provider_data
    +        
    +        # Save config
    +        try:
    +            self.app.services.config_svc.update_setting("sso", sso)
    +            printer.success(f"SSO Provider '{provider}' saved successfully.")
    +        except Exception as e:
    +            printer.error(f"Failed to save SSO configuration: {e}")
    +            sys.exit(1)
    +
    +    def delete_provider(self, args):
    +        provider = args.provider
    +        sso = self.app.config.config.get("sso", {})
    +        providers = sso.get("providers", {})
    +        
    +        if provider not in providers:
    +            printer.error(f"SSO Provider '{provider}' not found.")
    +            sys.exit(1)
    +            
    +        # Confirm delete
    +        questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
    +        answers = inquirer.prompt(questions)
    +        if not answers or not answers["confirm"]:
    +            printer.info("Delete cancelled.")
    +            return
    +            
    +        del providers[provider]
    +        
    +        # Save config
    +        try:
    +            self.app.services.config_svc.update_setting("sso", sso)
    +            printer.success(f"SSO Provider '{provider}' deleted successfully.")
    +        except Exception as e:
    +            printer.error(f"Failed to save SSO configuration: {e}")
    +            sys.exit(1)
    +
    +    def list_providers(self, args):
    +        sso = self.app.config.config.get("sso", {})
    +        providers = sso.get("providers", {})
    +        if not providers:
    +            printer.warning("No SSO providers configured.")
    +            return
    +            
    +        # Print list in YAML format
    +        providers_list = list(providers.keys())
    +        yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
    +        printer.data("Configured SSO Providers", yaml_str)
    +
    +    def show_provider(self, args):
    +        provider = args.provider
    +        sso = self.app.config.config.get("sso", {})
    +        providers = sso.get("providers", {})
    +        
    +        if provider not in providers:
    +            printer.error(f"SSO Provider '{provider}' not found.")
    +            sys.exit(1)
    +            
    +        data = providers[provider]
    +        
    +        # Mask client secret for display if it's sensitive and not an env var starting with $
    +        display_data = data.copy()
    +        secret = display_data.get("secret")
    +        if secret and not secret.startswith("$"):
    +            display_data["secret"] = "********"
    +            
    +        yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
    +        printer.data(f"SSO Provider: {provider}", yaml_str)
    +
    +
    +

    Methods

    +
    +
    +def add_provider(self, args) +
    +
    +
    + +Expand source code + +
    def add_provider(self, args):
    +    provider = args.provider
    +    sso = self.app.config.config.get("sso", {})
    +    providers = sso.setdefault("providers", {})
    +    
    +    existing = providers.get(provider, {})
    +    if existing:
    +        printer.warning(f"SSO Provider '{provider}' already exists. Overwriting/Editing it.")
    +    
    +    # Interactive questionnaire
    +    questions = [
    +        inquirer.Text("jwks_url", message="JWKS URL (optional, press Enter to skip)", default=existing.get("jwks_url", "")),
    +        inquirer.Text("secret", message="Client Secret / Shared Secret (optional, press Enter to skip)", default=existing.get("secret", "")),
    +        inquirer.Text("username_claim", message="Username Claim", default=existing.get("username_claim", "sub")),
    +        inquirer.Text("algorithms", message="Algorithms (comma separated)", default=",".join(existing.get("algorithms", ["RS256"]))),
    +        inquirer.Text("allowed_domains", message="Allowed/Trusted Email Domains (comma separated, optional)", default=",".join(existing.get("allowed_domains", [])))
    +    ]
    +    
    +    answers = inquirer.prompt(questions)
    +    if not answers:
    +        printer.warning("Operation cancelled.")
    +        sys.exit(130)
    +        
    +    jwks_url = answers["jwks_url"].strip()
    +    secret = answers["secret"].strip()
    +    username_claim = answers["username_claim"].strip()
    +    algorithms_str = answers["algorithms"].strip()
    +    allowed_domains_str = answers.get("allowed_domains", "").strip()
    +    
    +    if not jwks_url and not secret:
    +        printer.error("You must configure either a JWKS URL or a Secret.")
    +        sys.exit(1)
    +        
    +    if not username_claim:
    +        printer.error("Username claim cannot be empty.")
    +        sys.exit(1)
    +        
    +    algorithms = [alg.strip() for alg in algorithms_str.split(",") if alg.strip()]
    +    if not algorithms:
    +        algorithms = ["RS256"]
    +        
    +    allowed_domains = [domain.strip() for domain in allowed_domains_str.split(",") if domain.strip()]
    +        
    +    provider_data = {
    +        "username_claim": username_claim,
    +        "algorithms": algorithms
    +    }
    +    if jwks_url:
    +        provider_data["jwks_url"] = jwks_url
    +    if secret:
    +        provider_data["secret"] = secret
    +    if allowed_domains:
    +        provider_data["allowed_domains"] = allowed_domains
    +        
    +    providers[provider] = provider_data
    +    
    +    # Save config
    +    try:
    +        self.app.services.config_svc.update_setting("sso", sso)
    +        printer.success(f"SSO Provider '{provider}' saved successfully.")
    +    except Exception as e:
    +        printer.error(f"Failed to save SSO configuration: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def delete_provider(self, args) +
    +
    +
    + +Expand source code + +
    def delete_provider(self, args):
    +    provider = args.provider
    +    sso = self.app.config.config.get("sso", {})
    +    providers = sso.get("providers", {})
    +    
    +    if provider not in providers:
    +        printer.error(f"SSO Provider '{provider}' not found.")
    +        sys.exit(1)
    +        
    +    # Confirm delete
    +    questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
    +    answers = inquirer.prompt(questions)
    +    if not answers or not answers["confirm"]:
    +        printer.info("Delete cancelled.")
    +        return
    +        
    +    del providers[provider]
    +    
    +    # Save config
    +    try:
    +        self.app.services.config_svc.update_setting("sso", sso)
    +        printer.success(f"SSO Provider '{provider}' deleted successfully.")
    +    except Exception as e:
    +        printer.error(f"Failed to save SSO configuration: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def dispatch(self, args) +
    +
    +
    + +Expand source code + +
    def dispatch(self, args):
    +    if self.app.services.mode == "remote":
    +        printer.error("SSO management commands are only available in local/server-side mode.")
    +        sys.exit(1)
    +
    +    # Parse actions from argparse mutually exclusive options
    +    if getattr(args, "add", None):
    +        args.action = "add"
    +        args.provider = args.add[0]
    +    elif getattr(args, "delete", None):
    +        args.action = "del"
    +        args.provider = args.delete[0]
    +    elif getattr(args, "list", False):
    +        args.action = "list"
    +    elif getattr(args, "show", None):
    +        args.action = "show"
    +        args.provider = args.show[0]
    +
    +    action = getattr(args, "action", None)
    +    
    +    if action == "add":
    +        return self.add_provider(args)
    +    elif action == "del":
    +        return self.delete_provider(args)
    +    elif action == "list":
    +        return self.list_providers(args)
    +    elif action == "show":
    +        return self.show_provider(args)
    +    else:
    +        printer.error(f"Unknown action: {action}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def list_providers(self, args) +
    +
    +
    + +Expand source code + +
    def list_providers(self, args):
    +    sso = self.app.config.config.get("sso", {})
    +    providers = sso.get("providers", {})
    +    if not providers:
    +        printer.warning("No SSO providers configured.")
    +        return
    +        
    +    # Print list in YAML format
    +    providers_list = list(providers.keys())
    +    yaml_str = yaml.dump(providers_list, sort_keys=False, default_flow_style=False)
    +    printer.data("Configured SSO Providers", yaml_str)
    +
    +
    +
    +
    +def show_provider(self, args) +
    +
    +
    + +Expand source code + +
    def show_provider(self, args):
    +    provider = args.provider
    +    sso = self.app.config.config.get("sso", {})
    +    providers = sso.get("providers", {})
    +    
    +    if provider not in providers:
    +        printer.error(f"SSO Provider '{provider}' not found.")
    +        sys.exit(1)
    +        
    +    data = providers[provider]
    +    
    +    # Mask client secret for display if it's sensitive and not an env var starting with $
    +    display_data = data.copy()
    +    secret = display_data.get("secret")
    +    if secret and not secret.startswith("$"):
    +        display_data["secret"] = "********"
    +        
    +    yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
    +    printer.data(f"SSO Provider: {provider}", yaml_str)
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/connpy/grpc_layer/connpy_pb2_grpc.html b/docs/connpy/grpc_layer/connpy_pb2_grpc.html index 6eab6f0..d9af6ba 100644 --- a/docs/connpy/grpc_layer/connpy_pb2_grpc.html +++ b/docs/connpy/grpc_layer/connpy_pb2_grpc.html @@ -138,11 +138,21 @@ el.replaceWith(d); request_deserializer=connpy__pb2.LoginRequest.FromString, response_serializer=connpy__pb2.LoginResponse.SerializeToString, ), + 'login_sso': grpc.unary_unary_rpc_method_handler( + servicer.login_sso, + request_deserializer=connpy__pb2.LoginSSORequest.FromString, + response_serializer=connpy__pb2.LoginResponse.SerializeToString, + ), 'change_password': grpc.unary_unary_rpc_method_handler( servicer.change_password, request_deserializer=connpy__pb2.ChangePasswordRequest.FromString, response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, ), + 'get_sso_providers': grpc.unary_unary_rpc_method_handler( + servicer.get_sso_providers, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'connpy.AuthService', rpc_method_handlers) @@ -1690,6 +1700,33 @@ def predict_execution_results(request, metadata, _registered_method=True) + @staticmethod + def login_sso(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/connpy.AuthService/login_sso', + connpy__pb2.LoginSSORequest.SerializeToString, + connpy__pb2.LoginResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def change_password(request, target, @@ -1715,6 +1752,33 @@ def predict_execution_results(request, wait_for_ready, timeout, metadata, + _registered_method=True) + + @staticmethod + def get_sso_providers(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/connpy.AuthService/get_sso_providers', + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + connpy__pb2.SSOProvidersResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, _registered_method=True)

    Missing associated documentation comment in .proto file.

    @@ -1757,6 +1821,43 @@ def change_password(request,
    +
    +def get_sso_providers(request,
    target,
    options=(),
    channel_credentials=None,
    call_credentials=None,
    insecure=False,
    compression=None,
    wait_for_ready=None,
    timeout=None,
    metadata=None)
    +
    +
    +
    + +Expand source code + +
    @staticmethod
    +def get_sso_providers(request,
    +        target,
    +        options=(),
    +        channel_credentials=None,
    +        call_credentials=None,
    +        insecure=False,
    +        compression=None,
    +        wait_for_ready=None,
    +        timeout=None,
    +        metadata=None):
    +    return grpc.experimental.unary_unary(
    +        request,
    +        target,
    +        '/connpy.AuthService/get_sso_providers',
    +        google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
    +        connpy__pb2.SSOProvidersResponse.FromString,
    +        options,
    +        channel_credentials,
    +        insecure,
    +        call_credentials,
    +        compression,
    +        wait_for_ready,
    +        timeout,
    +        metadata,
    +        _registered_method=True)
    +
    +
    +
    def login(request,
    target,
    options=(),
    channel_credentials=None,
    call_credentials=None,
    insecure=False,
    compression=None,
    wait_for_ready=None,
    timeout=None,
    metadata=None)
    @@ -1794,6 +1895,43 @@ def login(request,
    +
    +def login_sso(request,
    target,
    options=(),
    channel_credentials=None,
    call_credentials=None,
    insecure=False,
    compression=None,
    wait_for_ready=None,
    timeout=None,
    metadata=None)
    +
    +
    +
    + +Expand source code + +
    @staticmethod
    +def login_sso(request,
    +        target,
    +        options=(),
    +        channel_credentials=None,
    +        call_credentials=None,
    +        insecure=False,
    +        compression=None,
    +        wait_for_ready=None,
    +        timeout=None,
    +        metadata=None):
    +    return grpc.experimental.unary_unary(
    +        request,
    +        target,
    +        '/connpy.AuthService/login_sso',
    +        connpy__pb2.LoginSSORequest.SerializeToString,
    +        connpy__pb2.LoginResponse.FromString,
    +        options,
    +        channel_credentials,
    +        insecure,
    +        call_credentials,
    +        compression,
    +        wait_for_ready,
    +        timeout,
    +        metadata,
    +        _registered_method=True)
    +
    +
    +
    @@ -1813,7 +1951,19 @@ def login(request, context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def login_sso(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def change_password(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def get_sso_providers(self, request, context): """Missing associated documentation comment in .proto file.""" context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') @@ -1842,6 +1992,22 @@ def login(request,

    Missing associated documentation comment in .proto file.

    +
    +def get_sso_providers(self, request, context) +
    +
    +
    + +Expand source code + +
    def get_sso_providers(self, request, context):
    +    """Missing associated documentation comment in .proto file."""
    +    context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +    context.set_details('Method not implemented!')
    +    raise NotImplementedError('Method not implemented!')
    +
    +

    Missing associated documentation comment in .proto file.

    +
    def login(self, request, context)
    @@ -1858,6 +2024,22 @@ def login(request,

    Missing associated documentation comment in .proto file.

    +
    +def login_sso(self, request, context) +
    +
    +
    + +Expand source code + +
    def login_sso(self, request, context):
    +    """Missing associated documentation comment in .proto file."""
    +    context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +    context.set_details('Method not implemented!')
    +    raise NotImplementedError('Method not implemented!')
    +
    +

    Missing associated documentation comment in .proto file.

    +
    @@ -1883,10 +2065,20 @@ def login(request, request_serializer=connpy__pb2.LoginRequest.SerializeToString, response_deserializer=connpy__pb2.LoginResponse.FromString, _registered_method=True) + self.login_sso = channel.unary_unary( + '/connpy.AuthService/login_sso', + request_serializer=connpy__pb2.LoginSSORequest.SerializeToString, + response_deserializer=connpy__pb2.LoginResponse.FromString, + _registered_method=True) self.change_password = channel.unary_unary( '/connpy.AuthService/change_password', request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString, response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True) + self.get_sso_providers = channel.unary_unary( + '/connpy.AuthService/get_sso_providers', + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=connpy__pb2.SSOProvidersResponse.FromString, _registered_method=True)

    Missing associated documentation comment in .proto file.

    @@ -6320,14 +6512,18 @@ def stop_api(request,

    AuthService

  • AuthServiceServicer

  • diff --git a/docs/connpy/grpc_layer/server.html b/docs/connpy/grpc_layer/server.html index 5b3b3ac..03e7a8c 100644 --- a/docs/connpy/grpc_layer/server.html +++ b/docs/connpy/grpc_layer/server.html @@ -111,6 +111,15 @@ el.replaceWith(d); fallback_provider = ServiceProvider(config, mode="local") registry = UserRegistry(config.defaultdir) + # Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env + import os + if os.getenv("CONN_SSO_GATEWAY_SECRET") and registry._shared_config: + sso_config = registry._shared_config.config.get("sso", {}) + providers = sso_config.get("providers", {}) + if "trusted_gateway" not in providers: + from connpy import printer + printer.warning("CONN_SSO_GATEWAY_SECRET is defined in environment, but 'trusted_gateway' is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.") + interceptors = [] if debug: interceptors.append(LoggingInterceptor()) @@ -487,7 +496,7 @@ def service(self): Expand source code
    class AuthInterceptor(grpc.ServerInterceptor):
    -    OPEN_METHODS = ["/connpy.AuthService/login"]
    +    OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
     
         def __init__(self, registry):
             self.registry = registry
    @@ -674,7 +683,7 @@ interceptor chooses to service this RPC, or None otherwise.

  • context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password") token = self.registry.user_service.generate_jwt(username) - expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp()) + expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp()) return connpy_pb2.LoginResponse( token=token, @@ -682,6 +691,137 @@ interceptor chooses to service this RPC, or None otherwise.

    expires_at=expires_at ) + @handle_errors + def login_sso(self, request, context): + username = request.username + id_token = request.id_token + provider = request.provider + + if not id_token or not provider: + context.abort(grpc.StatusCode.INVALID_ARGUMENT, "id_token and provider are required") + + # Load SSO configuration + sso_config = {} + if self.registry: + shared_config = self.registry.get_shared_config() + if shared_config: + sso_config = shared_config.config.get("sso", {}) + + providers = sso_config.get("providers", {}) + if provider not in providers: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"SSO Provider '{provider}' not configured in config.yaml") + + p_config = providers[provider] + jwks_url = p_config.get("jwks_url") + secret = p_config.get("secret") + + if secret and secret.startswith("$"): + import os + secret = os.getenv(secret[1:]) + + if not jwks_url and not secret: + context.abort(grpc.StatusCode.FAILED_PRECONDITION, f"Provider '{provider}' has no jwks_url or secret configured") + + # Validate token + import jwt + try: + algorithms = p_config.get("algorithms", ["RS256"] if jwks_url else ["HS256"]) + verify_aud = "audience" in p_config + audience = p_config.get("audience") + verify_iss = "issuer" in p_config + issuer = p_config.get("issuer") + + options = { + "verify_signature": True, + "verify_exp": True, + "verify_aud": verify_aud, + "verify_iss": verify_iss + } + + decode_kwargs = { + "algorithms": algorithms, + "options": options + } + if verify_aud: + decode_kwargs["audience"] = audience + if verify_iss: + decode_kwargs["issuer"] = issuer + + if jwks_url: + from jwt import PyJWKClient + jwks_client = PyJWKClient(jwks_url) + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + payload = jwt.decode(id_token, signing_key.key, **decode_kwargs) + else: + payload = jwt.decode(id_token, secret, **decode_kwargs) + + except Exception as e: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO Token validation failed: {str(e)}") + + # Extract username from claim + username_claim = p_config.get("username_claim", "sub") + claim_username = payload.get(username_claim) + if not claim_username: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Username claim '{username_claim}' not found in SSO Token") + + # Check domain restrictions (allowed_domains) + allowed_domains = p_config.get("allowed_domains", []) + if allowed_domains: + email = payload.get("email") + if not email and claim_username and "@" in claim_username: + email = claim_username + + if not email: + context.abort(grpc.StatusCode.UNAUTHENTICATED, "Domain restriction enabled but no email claim found in SSO Token") + + try: + user_domain = email.split("@")[-1].strip().lower() + except Exception: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Invalid email format in SSO Token: '{email}'") + + allowed_domains_lower = [d.strip().lower() for d in allowed_domains if d] + if user_domain not in allowed_domains_lower: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"SSO user domain '{user_domain}' not allowed") + + # Normalize username to alphanumeric/dashes/underscores to match connpy's username regex + import re + normalized_username = re.sub(r'[^a-zA-Z0-9_-]', '_', claim_username.split('@')[0]) + + # If a requested username was sent, verify it matches + if username and username != normalized_username: + context.abort(grpc.StatusCode.UNAUTHENTICATED, f"Mismatched username. Expected '{normalized_username}', got '{username}'") + + # Check if user exists in connpy registry, otherwise auto-provision + try: + user_exists = any(u["username"] == normalized_username for u in self.registry.user_service.list_users()) + if not user_exists: + import secrets + # Provision new user with random password (never used directly) + self.registry.user_service.create_user(normalized_username, secrets.token_hex(32)) + except Exception as e: + context.abort(grpc.StatusCode.INTERNAL, f"Failed to auto-provision user: {str(e)}") + + # Generate native connpy JWT token + token = self.registry.user_service.generate_jwt(normalized_username) + expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12)).timestamp()) + + return connpy_pb2.LoginResponse( + token=token, + username=normalized_username, + expires_at=expires_at + ) + + @handle_errors + def get_sso_providers(self, request, context): + sso_config = {} + if self.registry: + shared_config = self.registry.get_shared_config() + if shared_config: + sso_config = shared_config.config.get("sso", {}) + providers = list(sso_config.get("providers", {}).keys()) + external_providers = [p for p in providers if p != "trusted_gateway"] + return connpy_pb2.SSOProvidersResponse(providers=external_providers) + @handle_errors def change_password(self, request, context): username = _current_user.get() @@ -706,7 +846,9 @@ interceptor chooses to service this RPC, or None otherwise.

  • AuthServiceServicer:
  • diff --git a/docs/connpy/grpc_layer/user_registry.html b/docs/connpy/grpc_layer/user_registry.html index 8f05673..9fc72d2 100644 --- a/docs/connpy/grpc_layer/user_registry.html +++ b/docs/connpy/grpc_layer/user_registry.html @@ -143,6 +143,12 @@ el.replaceWith(d); def has_users(self) -> bool: """Check if any users are registered (enables auth enforcement).""" return bool(self.user_service.list_users()) + + def get_shared_config(self): + """Thread-safe access to the hot-reloaded shared configuration.""" + with self._lock: + self._refresh_shared() + return self._shared_config def evict(self, username): """Remove and cleanly shut down cached provider (after delete or password change).""" @@ -244,6 +250,22 @@ el.replaceWith(d);

    Get, lazy-load, or hot-reload a user's full ServiceProvider.

    +
    +def get_shared_config(self) +
    +
    +
    + +Expand source code + +
    def get_shared_config(self):
    +    """Thread-safe access to the hot-reloaded shared configuration."""
    +    with self._lock:
    +        self._refresh_shared()
    +        return self._shared_config
    +
    +

    Thread-safe access to the hot-reloaded shared configuration.

    +
    def has_users(self) ‑>Β bool
    @@ -280,6 +302,7 @@ el.replaceWith(d); diff --git a/docs/connpy/index.html b/docs/connpy/index.html index 9f90071..42f19c1 100644 --- a/docs/connpy/index.html +++ b/docs/connpy/index.html @@ -125,6 +125,24 @@ conn ai # Run a command on all nodes in a folder conn run @office "uptime"
    +

    πŸ”‘ SSO / OIDC Provider Management

    +

    In remote mode, connpy supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the conn sso command suite:

    +
      +
    • List configured providers: +bash +conn sso --list
    • +
    • Show provider details (sensitive credentials like secrets are masked): +bash +conn sso --show <provider_name>
    • +
    • Add or update a provider (opens an interactive configuration wizard): +bash +conn sso --add <provider_name>
    • +
    • Delete a provider: +bash +conn sso --del <provider_name>
    • +
    +

    Security Recommendation (Secret Reference Env Vars)

    +

    To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a $ instead of the literal secret during the conn sso --add prompts (e.g., $CONN_SSO_MYPROVIDER_SECRET). The backend gRPC server will dynamically resolve the value from its environment variables at runtime.


    Plugin Requirements for Connpy

    Remote Plugin Execution

    @@ -6433,6 +6451,10 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
  • Usage
  • Plugin Requirements for Connpy
      diff --git a/docs/connpy/services/user_service.html b/docs/connpy/services/user_service.html index 75aaf0f..e9fe784 100644 --- a/docs/connpy/services/user_service.html +++ b/docs/connpy/services/user_service.html @@ -256,7 +256,7 @@ el.replaceWith(d); return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8")) def generate_jwt(self, username) -> str: - """Generates a secure JSON Web Token for the user expiring in 8 hours.""" + """Generates a secure JSON Web Token for the user expiring in 12 hours.""" registry = self._load_registry() if username not in registry["users"]: raise ValueError(f"User '{username}' not found") @@ -267,7 +267,8 @@ el.replaceWith(d); "exp": expiration } - token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256") + secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"] + token = jwt.encode(payload, secret, algorithm="HS256") if isinstance(token, bytes): token = token.decode("utf-8") @@ -277,7 +278,8 @@ el.replaceWith(d); """Decodes JWT and returns username if token is valid and unexpired.""" registry = self._load_registry() try: - payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"]) + secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"] + payload = jwt.decode(token, secret, algorithms=["HS256"]) return payload.get("sub") except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError): return None @@ -468,7 +470,7 @@ Mode B: config_path set -> Reuses existing directory after validating its str Expand source code
      def generate_jwt(self, username) -> str:
      -    """Generates a secure JSON Web Token for the user expiring in 8 hours."""
      +    """Generates a secure JSON Web Token for the user expiring in 12 hours."""
           registry = self._load_registry()
           if username not in registry["users"]:
               raise ValueError(f"User '{username}' not found")
      @@ -479,13 +481,14 @@ Mode B: config_path set -> Reuses existing directory after validating its str
               "exp": expiration
           }
           
      -    token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
      +    secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"]
      +    token = jwt.encode(payload, secret, algorithm="HS256")
           if isinstance(token, bytes):
               token = token.decode("utf-8")
               
           return token
      -

      Generates a secure JSON Web Token for the user expiring in 8 hours.

      +

      Generates a secure JSON Web Token for the user expiring in 12 hours.

      def get_user(self, username) ‑>Β dict @@ -545,7 +548,8 @@ Mode B: config_path set -> Reuses existing directory after validating its str """Decodes JWT and returns username if token is valid and unexpired.""" registry = self._load_registry() try: - payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"]) + secret = os.environ.get("CONNPY_JWT_SECRET") or registry["jwt_secret"] + payload = jwt.decode(token, secret, algorithms=["HS256"]) return payload.get("sub") except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError): return None