From 744e7306725f74b7415b55cecb0bfe5091cd2ca0 Mon Sep 17 00:00:00 2001
From: Fede Luzzi
Date: Thu, 4 Jun 2026 18:33:26 -0300
Subject: [PATCH] feat(auth,cli): add SSO/OIDC authentication and provider
management
- Introduce `conn sso` CLI suite for managing Identity Providers (IdP).
- Implement `login_sso` and `get_sso_providers` in gRPC AuthService.
- Add auto-provisioning for users logging in via SSO.
- Support JWT validation via shared secrets (HS256) or JWKS (RS256).
- Add domain restriction (`allowed_domains`) and env-var secret resolution.
- Increase JWT session expiration from 8 to 12 hours.
- Add shell autocompletion for SSO commands and configured providers.
- Bump version to 6.0.3.
---
README.md | 23 +
connpy/__init__.py | 23 +
connpy/_version.py | 2 +-
connpy/cli/__init__.py | 1 +
connpy/cli/sso_handler.py | 162 +++++++
connpy/completion.py | 34 ++
connpy/connapp.py | 14 +-
connpy/grpc_layer/connpy_pb2.py | 62 +--
connpy/grpc_layer/connpy_pb2_grpc.py | 86 ++++
connpy/grpc_layer/server.py | 144 +++++-
connpy/grpc_layer/user_registry.py | 6 +
connpy/proto/connpy.proto | 12 +
connpy/services/user_service.py | 8 +-
connpy/tests/test_cli_sso.py | 67 +++
connpy/tests/test_completion.py | 43 ++
connpy/tests/test_multiuser_grpc_auth.py | 229 ++++++++++
docs/connpy/cli/index.html | 5 +
docs/connpy/cli/sso_handler.html | 459 ++++++++++++++++++++
docs/connpy/grpc_layer/connpy_pb2_grpc.html | 196 +++++++++
docs/connpy/grpc_layer/server.html | 146 ++++++-
docs/connpy/grpc_layer/user_registry.html | 23 +
docs/connpy/index.html | 22 +
docs/connpy/services/user_service.html | 18 +-
23 files changed, 1740 insertions(+), 45 deletions(-)
create mode 100644 connpy/cli/sso_handler.py
create mode 100644 connpy/tests/test_cli_sso.py
create mode 100644 docs/connpy/cli/sso_handler.html
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
+
+
+
+
+
+
+
+
+
+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,
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