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.
This commit is contained in:
2026-06-04 18:33:26 -03:00
parent 61a44d004f
commit 744e730672
23 changed files with 1740 additions and 45 deletions
+23
View File
@@ -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 <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 System
+23
View File
@@ -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 <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
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.2"
__version__ = "6.0.3"
+1
View File
@@ -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
+162
View File
@@ -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)
+34
View File
@@ -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,
+13 -1
View File
@@ -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
File diff suppressed because one or more lines are too long
+86
View File
@@ -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)
+142 -2
View File
@@ -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())
+6
View File
@@ -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)."""
+12
View File
@@ -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;
+5 -3
View File
@@ -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
+67
View File
@@ -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
+43
View File
@@ -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
+229
View File
@@ -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
+5
View File
@@ -92,6 +92,10 @@ el.replaceWith(d);
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></dt>
<dd>
<div class="desc"></div>
@@ -142,6 +146,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
<li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li>
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
<li><code><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></li>
+459
View File
@@ -0,0 +1,459 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.sso_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.sso_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler"><code class="flex name class">
<span>class <span class="ident">SSOHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class SSOHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)
def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
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(&#34;Configured SSO Providers&#34;, yaml_str)
def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.sso_handler.SSOHandler.add_provider"><code class="name flex">
<span>def <span class="ident">add_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {})
existing = providers.get(provider, {})
if existing:
printer.warning(f&#34;SSO Provider &#39;{provider}&#39; already exists. Overwriting/Editing it.&#34;)
# Interactive questionnaire
questions = [
inquirer.Text(&#34;jwks_url&#34;, message=&#34;JWKS URL (optional, press Enter to skip)&#34;, default=existing.get(&#34;jwks_url&#34;, &#34;&#34;)),
inquirer.Text(&#34;secret&#34;, message=&#34;Client Secret / Shared Secret (optional, press Enter to skip)&#34;, default=existing.get(&#34;secret&#34;, &#34;&#34;)),
inquirer.Text(&#34;username_claim&#34;, message=&#34;Username Claim&#34;, default=existing.get(&#34;username_claim&#34;, &#34;sub&#34;)),
inquirer.Text(&#34;algorithms&#34;, message=&#34;Algorithms (comma separated)&#34;, default=&#34;,&#34;.join(existing.get(&#34;algorithms&#34;, [&#34;RS256&#34;]))),
inquirer.Text(&#34;allowed_domains&#34;, message=&#34;Allowed/Trusted Email Domains (comma separated, optional)&#34;, default=&#34;,&#34;.join(existing.get(&#34;allowed_domains&#34;, [])))
]
answers = inquirer.prompt(questions)
if not answers:
printer.warning(&#34;Operation cancelled.&#34;)
sys.exit(130)
jwks_url = answers[&#34;jwks_url&#34;].strip()
secret = answers[&#34;secret&#34;].strip()
username_claim = answers[&#34;username_claim&#34;].strip()
algorithms_str = answers[&#34;algorithms&#34;].strip()
allowed_domains_str = answers.get(&#34;allowed_domains&#34;, &#34;&#34;).strip()
if not jwks_url and not secret:
printer.error(&#34;You must configure either a JWKS URL or a Secret.&#34;)
sys.exit(1)
if not username_claim:
printer.error(&#34;Username claim cannot be empty.&#34;)
sys.exit(1)
algorithms = [alg.strip() for alg in algorithms_str.split(&#34;,&#34;) if alg.strip()]
if not algorithms:
algorithms = [&#34;RS256&#34;]
allowed_domains = [domain.strip() for domain in allowed_domains_str.split(&#34;,&#34;) if domain.strip()]
provider_data = {
&#34;username_claim&#34;: username_claim,
&#34;algorithms&#34;: algorithms
}
if jwks_url:
provider_data[&#34;jwks_url&#34;] = jwks_url
if secret:
provider_data[&#34;secret&#34;] = secret
if allowed_domains:
provider_data[&#34;allowed_domains&#34;] = allowed_domains
providers[provider] = provider_data
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; saved successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.delete_provider"><code class="name flex">
<span>def <span class="ident">delete_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
# Confirm delete
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]:
printer.info(&#34;Delete cancelled.&#34;)
return
del providers[provider]
# Save config
try:
self.app.services.config_svc.update_setting(&#34;sso&#34;, sso)
printer.success(f&#34;SSO Provider &#39;{provider}&#39; deleted successfully.&#34;)
except Exception as e:
printer.error(f&#34;Failed to save SSO configuration: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;SSO management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.provider = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.provider = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.provider = args.show[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_provider(args)
elif action == &#34;del&#34;:
return self.delete_provider(args)
elif action == &#34;list&#34;:
return self.list_providers(args)
elif action == &#34;show&#34;:
return self.show_provider(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.list_providers"><code class="name flex">
<span>def <span class="ident">list_providers</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_providers(self, args):
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if not providers:
printer.warning(&#34;No SSO providers configured.&#34;)
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(&#34;Configured SSO Providers&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.sso_handler.SSOHandler.show_provider"><code class="name flex">
<span>def <span class="ident">show_provider</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_provider(self, args):
provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.get(&#34;providers&#34;, {})
if provider not in providers:
printer.error(f&#34;SSO Provider &#39;{provider}&#39; not found.&#34;)
sys.exit(1)
data = providers[provider]
# Mask client secret for display if it&#39;s sensitive and not an env var starting with $
display_data = data.copy()
secret = display_data.get(&#34;secret&#34;)
if secret and not secret.startswith(&#34;$&#34;):
display_data[&#34;secret&#34;] = &#34;********&#34;
yaml_str = yaml.dump(display_data, sort_keys=False, default_flow_style=False)
printer.data(f&#34;SSO Provider: {provider}&#34;, yaml_str)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.sso_handler.SSOHandler" href="#connpy.cli.sso_handler.SSOHandler">SSOHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.sso_handler.SSOHandler.add_provider" href="#connpy.cli.sso_handler.SSOHandler.add_provider">add_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.delete_provider" href="#connpy.cli.sso_handler.SSOHandler.delete_provider">delete_provider</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.dispatch" href="#connpy.cli.sso_handler.SSOHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.list_providers" href="#connpy.cli.sso_handler.SSOHandler.list_providers">list_providers</a></code></li>
<li><code><a title="connpy.cli.sso_handler.SSOHandler.show_provider" href="#connpy.cli.sso_handler.SSOHandler.show_provider">show_provider</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer>
</body>
</html>
+196
View File
@@ -138,11 +138,21 @@ el.replaceWith(d);
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
&#39;login_sso&#39;: grpc.unary_unary_rpc_method_handler(
servicer.login_sso,
request_deserializer=connpy__pb2.LoginSSORequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
&#39;change_password&#39;: 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,
),
&#39;get_sso_providers&#39;: 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(
&#39;connpy.AuthService&#39;, 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,
&#39;/connpy.AuthService/login_sso&#39;,
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,
&#39;/connpy.AuthService/get_sso_providers&#39;,
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)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -1757,6 +1821,43 @@ def change_password(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@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,
&#39;/connpy.AuthService/get_sso_providers&#39;,
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)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
@@ -1794,6 +1895,43 @@ def login(request,
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso"><code class="name flex">
<span>def <span class="ident">login_sso</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@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,
&#39;/connpy.AuthService/login_sso&#39;,
connpy__pb2.LoginSSORequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
@@ -1813,7 +1951,19 @@ def login(request,
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def login_sso(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def change_password(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def get_sso_providers(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
@@ -1842,6 +1992,22 @@ def login(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_sso_providers(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
</code></dt>
@@ -1858,6 +2024,22 @@ def login(request,
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso"><code class="name flex">
<span>def <span class="ident">login_sso</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def login_sso(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
@@ -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(
&#39;/connpy.AuthService/login_sso&#39;,
request_serializer=connpy__pb2.LoginSSORequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
&#39;/connpy.AuthService/change_password&#39;,
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(
&#39;/connpy.AuthService/get_sso_providers&#39;,
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
@@ -6320,14 +6512,18 @@ def stop_api(request,
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
</ul>
</li>
<li>
+144 -2
View File
@@ -111,6 +111,15 @@ el.replaceWith(d);
fallback_provider = ServiceProvider(config, mode=&#34;local&#34;)
registry = UserRegistry(config.defaultdir)
# Check if trusted_gateway provider is configured if SSO Gateway Secret is present in env
import os
if os.getenv(&#34;CONN_SSO_GATEWAY_SECRET&#34;) and registry._shared_config:
sso_config = registry._shared_config.config.get(&#34;sso&#34;, {})
providers = sso_config.get(&#34;providers&#34;, {})
if &#34;trusted_gateway&#34; not in providers:
from connpy import printer
printer.warning(&#34;CONN_SSO_GATEWAY_SECRET is defined in environment, but &#39;trusted_gateway&#39; is not configured as an SSO provider in config.yaml. Forward Auth flow will not work.&#34;)
interceptors = []
if debug:
interceptors.append(LoggingInterceptor())
@@ -487,7 +496,7 @@ def service(self):
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = [&#34;/connpy.AuthService/login&#34;]
OPEN_METHODS = [&#34;/connpy.AuthService/login&#34;, &#34;/connpy.AuthService/login_sso&#34;, &#34;/connpy.AuthService/get_sso_providers&#34;]
def __init__(self, registry):
self.registry = registry
@@ -674,7 +683,7 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Invalid username or password&#34;)
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.</p></div>
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, &#34;id_token and provider are required&#34;)
# Load SSO configuration
sso_config = {}
if self.registry:
shared_config = self.registry.get_shared_config()
if shared_config:
sso_config = shared_config.config.get(&#34;sso&#34;, {})
providers = sso_config.get(&#34;providers&#34;, {})
if provider not in providers:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f&#34;SSO Provider &#39;{provider}&#39; not configured in config.yaml&#34;)
p_config = providers[provider]
jwks_url = p_config.get(&#34;jwks_url&#34;)
secret = p_config.get(&#34;secret&#34;)
if secret and secret.startswith(&#34;$&#34;):
import os
secret = os.getenv(secret[1:])
if not jwks_url and not secret:
context.abort(grpc.StatusCode.FAILED_PRECONDITION, f&#34;Provider &#39;{provider}&#39; has no jwks_url or secret configured&#34;)
# Validate token
import jwt
try:
algorithms = p_config.get(&#34;algorithms&#34;, [&#34;RS256&#34;] if jwks_url else [&#34;HS256&#34;])
verify_aud = &#34;audience&#34; in p_config
audience = p_config.get(&#34;audience&#34;)
verify_iss = &#34;issuer&#34; in p_config
issuer = p_config.get(&#34;issuer&#34;)
options = {
&#34;verify_signature&#34;: True,
&#34;verify_exp&#34;: True,
&#34;verify_aud&#34;: verify_aud,
&#34;verify_iss&#34;: verify_iss
}
decode_kwargs = {
&#34;algorithms&#34;: algorithms,
&#34;options&#34;: options
}
if verify_aud:
decode_kwargs[&#34;audience&#34;] = audience
if verify_iss:
decode_kwargs[&#34;issuer&#34;] = 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&#34;SSO Token validation failed: {str(e)}&#34;)
# Extract username from claim
username_claim = p_config.get(&#34;username_claim&#34;, &#34;sub&#34;)
claim_username = payload.get(username_claim)
if not claim_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Username claim &#39;{username_claim}&#39; not found in SSO Token&#34;)
# Check domain restrictions (allowed_domains)
allowed_domains = p_config.get(&#34;allowed_domains&#34;, [])
if allowed_domains:
email = payload.get(&#34;email&#34;)
if not email and claim_username and &#34;@&#34; in claim_username:
email = claim_username
if not email:
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Domain restriction enabled but no email claim found in SSO Token&#34;)
try:
user_domain = email.split(&#34;@&#34;)[-1].strip().lower()
except Exception:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Invalid email format in SSO Token: &#39;{email}&#39;&#34;)
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&#34;SSO user domain &#39;{user_domain}&#39; not allowed&#34;)
# Normalize username to alphanumeric/dashes/underscores to match connpy&#39;s username regex
import re
normalized_username = re.sub(r&#39;[^a-zA-Z0-9_-]&#39;, &#39;_&#39;, claim_username.split(&#39;@&#39;)[0])
# If a requested username was sent, verify it matches
if username and username != normalized_username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, f&#34;Mismatched username. Expected &#39;{normalized_username}&#39;, got &#39;{username}&#39;&#34;)
# Check if user exists in connpy registry, otherwise auto-provision
try:
user_exists = any(u[&#34;username&#34;] == 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&#34;Failed to auto-provision user: {str(e)}&#34;)
# 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(&#34;sso&#34;, {})
providers = list(sso_config.get(&#34;providers&#34;, {}).keys())
external_providers = [p for p in providers if p != &#34;trusted_gateway&#34;]
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.</p></div>
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
<ul class="hlist">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
</ul>
</li>
</ul>
+23
View File
@@ -143,6 +143,12 @@ el.replaceWith(d);
def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())
def get_shared_config(self):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
with self._lock:
self._refresh_shared()
return self._shared_config
def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
@@ -244,6 +250,22 @@ el.replaceWith(d);
</details>
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config"><code class="name flex">
<span>def <span class="ident">get_shared_config</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_shared_config(self):
&#34;&#34;&#34;Thread-safe access to the hot-reloaded shared configuration.&#34;&#34;&#34;
with self._lock:
self._refresh_shared()
return self._shared_config</code></pre>
</details>
<div class="desc"><p>Thread-safe access to the hot-reloaded shared configuration.</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
<span>def <span class="ident">has_users</span></span>(<span>self) > bool</span>
</code></dt>
@@ -280,6 +302,7 @@ el.replaceWith(d);
<ul class="">
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.evict" href="#connpy.grpc_layer.user_registry.UserRegistry.evict">evict</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_provider" href="#connpy.grpc_layer.user_registry.UserRegistry.get_provider">get_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_shared_config" href="#connpy.grpc_layer.user_registry.UserRegistry.get_shared_config">get_shared_config</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.has_users" href="#connpy.grpc_layer.user_registry.UserRegistry.has_users">has_users</a></code></li>
</ul>
</li>
+22
View File
@@ -125,6 +125,24 @@ conn ai
# Run a command on all nodes in a folder
conn run @office &quot;uptime&quot;
</code></pre>
<h3 id="sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</h3>
<p>In remote mode, <code><a title="connpy" href="#connpy">connpy</a></code> supports Single Sign-On (SSO) login. You can manage the configured identity providers (IdPs) directly from the local CLI using the <code>conn sso</code> command suite:</p>
<ul>
<li><strong>List configured providers</strong>:
<code>bash
conn sso --list</code></li>
<li><strong>Show provider details</strong> (sensitive credentials like secrets are masked):
<code>bash
conn sso --show &lt;provider_name&gt;</code></li>
<li><strong>Add or update a provider</strong> (opens an interactive configuration wizard):
<code>bash
conn sso --add &lt;provider_name&gt;</code></li>
<li><strong>Delete a provider</strong>:
<code>bash
conn sso --del &lt;provider_name&gt;</code></li>
</ul>
<h4 id="security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</h4>
<p>To keep sensitive client secrets or shared secrets out of git-tracked configuration files, you can input a variable name prefixed with a <code>$</code> instead of the literal secret during the <code>conn sso --add</code> prompts (e.g., <code>$CONN_SSO_MYPROVIDER_SECRET</code>). The backend gRPC server will dynamically resolve the value from its environment variables at runtime.</p>
<hr>
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
@@ -6433,6 +6451,10 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
</li>
<li><a href="#usage">Usage</a><ul>
<li><a href="#basic-examples">Basic Examples:</a></li>
<li><a href="#sso-oidc-provider-management">🔑 SSO / OIDC Provider Management</a><ul>
<li><a href="#security-recommendation-secret-reference-env-vars">Security Recommendation (Secret Reference Env Vars)</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
+11 -7
View File
@@ -256,7 +256,7 @@ el.replaceWith(d);
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))
def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#34;&#34;&#34;
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
@@ -267,7 +267,8 @@ el.replaceWith(d);
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
@@ -277,7 +278,8 @@ el.replaceWith(d);
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
@@ -468,7 +470,7 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
<span>Expand source code</span>
</summary>
<pre><code class="python">def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#34;&#34;&#34;
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 12 hours.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
@@ -479,13 +481,14 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
token = jwt.encode(payload, secret, algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token</code></pre>
</details>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 8 hours.</p></div>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 12 hours.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
<span>def <span class="ident">get_user</span></span>(<span>self, username) > dict</span>
@@ -545,7 +548,8 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
secret = os.environ.get(&#34;CONNPY_JWT_SECRET&#34;) or registry[&#34;jwt_secret&#34;]
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>