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:
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "6.0.2"
|
||||
__version__ = "6.0.3"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -93,6 +93,12 @@ class UserRegistry:
|
||||
"""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)."""
|
||||
with self._lock:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 == "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)</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("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)</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("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)</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 == "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)</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("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)</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("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)</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>
|
||||
@@ -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)</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,
|
||||
'/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)</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,
|
||||
'/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)</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('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,
|
||||
</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):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</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):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</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(
|
||||
'/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)</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>
|
||||
|
||||
@@ -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):
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">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.</p></div>
|
||||
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.</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, "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.</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>
|
||||
|
||||
@@ -144,6 +144,12 @@ el.replaceWith(d);
|
||||
"""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)."""
|
||||
with self._lock:
|
||||
@@ -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):
|
||||
"""Thread-safe access to the hot-reloaded shared configuration."""
|
||||
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>
|
||||
|
||||
@@ -125,6 +125,24 @@ conn ai
|
||||
# Run a command on all nodes in a folder
|
||||
conn run @office "uptime"
|
||||
</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 <provider_name></code></li>
|
||||
<li><strong>Add or update a provider</strong> (opens an interactive configuration wizard):
|
||||
<code>bash
|
||||
conn sso --add <provider_name></code></li>
|
||||
<li><strong>Delete a provider</strong>:
|
||||
<code>bash
|
||||
conn sso --del <provider_name></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>
|
||||
|
||||
@@ -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</code></pre>
|
||||
@@ -468,7 +470,7 @@ Mode B: config_path set -> Reuses existing directory after validating its str
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<pre><code class="python">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</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 -> 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</code></pre>
|
||||
|
||||
Reference in New Issue
Block a user