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"
|
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
|
## 🔌 Plugin System
|
||||||
|
|||||||
@@ -106,6 +106,29 @@ conn ai
|
|||||||
conn run @office "uptime"
|
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
|
## 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 .plugin_handler import PluginHandler
|
||||||
from .import_export_handler import ImportExportHandler
|
from .import_export_handler import ImportExportHandler
|
||||||
from .context_handler import ContextHandler
|
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 []
|
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):
|
def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||||
"""Build the declarative CLI navigation tree.
|
"""Build the declarative CLI navigation tree.
|
||||||
|
|
||||||
@@ -236,6 +257,18 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
|||||||
"--help": None, "-h": None
|
"--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}
|
mv_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
||||||
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
||||||
ls_state = {
|
ls_state = {
|
||||||
@@ -331,6 +364,7 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
|||||||
"-h": None,
|
"-h": None,
|
||||||
},
|
},
|
||||||
"user": user_dict,
|
"user": user_dict,
|
||||||
|
"sso": sso_dict,
|
||||||
"login": {"--help": None, "-h": None, "*": None},
|
"login": {"--help": None, "-h": None, "*": None},
|
||||||
"logout": {"--help": None, "-h": None},
|
"logout": {"--help": None, "-h": None},
|
||||||
"config": config_dict,
|
"config": config_dict,
|
||||||
|
|||||||
+13
-1
@@ -37,7 +37,7 @@ RichHelpFormatter.group_name_formatter = str.upper
|
|||||||
from .cli import (
|
from .cli import (
|
||||||
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
|
NodeHandler, ProfileHandler, ConfigHandler, RunHandler,
|
||||||
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
|
AIHandler, APIHandler, PluginHandler, ImportExportHandler,
|
||||||
ContextHandler
|
ContextHandler, SSOHandler
|
||||||
)
|
)
|
||||||
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
|
from .cli.helpers import nodes_completer, folders_completer, profiles_completer
|
||||||
from .cli.help_text import get_help
|
from .cli.help_text import get_help
|
||||||
@@ -141,6 +141,7 @@ class connapp:
|
|||||||
from .cli.sync_handler import SyncHandler
|
from .cli.sync_handler import SyncHandler
|
||||||
from .cli.user_handler import UserHandler
|
from .cli.user_handler import UserHandler
|
||||||
from .cli.login_handler import LoginHandler
|
from .cli.login_handler import LoginHandler
|
||||||
|
from .cli.sso_handler import SSOHandler
|
||||||
|
|
||||||
# Instantiate Handlers
|
# Instantiate Handlers
|
||||||
self._node = NodeHandler(self)
|
self._node = NodeHandler(self)
|
||||||
@@ -155,6 +156,7 @@ class connapp:
|
|||||||
self._sync = SyncHandler(self)
|
self._sync = SyncHandler(self)
|
||||||
self._user = UserHandler(self)
|
self._user = UserHandler(self)
|
||||||
self._login = LoginHandler(self)
|
self._login = LoginHandler(self)
|
||||||
|
self._sso = SSOHandler(self)
|
||||||
|
|
||||||
# Register auto-sync hook to trigger after config saves
|
# Register auto-sync hook to trigger after config saves
|
||||||
from .configfile import configfile
|
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.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)")
|
||||||
userparser.set_defaults(func=self._user.dispatch)
|
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
|
||||||
loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter)
|
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
|
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,
|
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
_registered_method=True)
|
_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(
|
self.change_password = channel.unary_unary(
|
||||||
'/connpy.AuthService/change_password',
|
'/connpy.AuthService/change_password',
|
||||||
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
||||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
||||||
_registered_method=True)
|
_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):
|
class AuthServiceServicer(object):
|
||||||
@@ -2653,12 +2663,24 @@ class AuthServiceServicer(object):
|
|||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('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):
|
def change_password(self, request, context):
|
||||||
"""Missing associated documentation comment in .proto file."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('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):
|
def add_AuthServiceServicer_to_server(servicer, server):
|
||||||
rpc_method_handlers = {
|
rpc_method_handlers = {
|
||||||
@@ -2667,11 +2689,21 @@ def add_AuthServiceServicer_to_server(servicer, server):
|
|||||||
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
||||||
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
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(
|
'change_password': grpc.unary_unary_rpc_method_handler(
|
||||||
servicer.change_password,
|
servicer.change_password,
|
||||||
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
||||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
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(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AuthService', rpc_method_handlers)
|
'connpy.AuthService', rpc_method_handlers)
|
||||||
@@ -2710,6 +2742,33 @@ class AuthService(object):
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_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
|
@staticmethod
|
||||||
def change_password(request,
|
def change_password(request,
|
||||||
target,
|
target,
|
||||||
@@ -2736,3 +2795,30 @@ class AuthService(object):
|
|||||||
timeout,
|
timeout,
|
||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_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")
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
||||||
|
|
||||||
token = self.registry.user_service.generate_jwt(username)
|
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(
|
return connpy_pb2.LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -1281,6 +1281,137 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
|||||||
expires_at=expires_at
|
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
|
@handle_errors
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
username = _current_user.get()
|
username = _current_user.get()
|
||||||
@@ -1296,7 +1427,7 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
|
|||||||
return Empty()
|
return Empty()
|
||||||
|
|
||||||
class AuthInterceptor(grpc.ServerInterceptor):
|
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):
|
def __init__(self, registry):
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
@@ -1422,6 +1553,15 @@ def serve(config, port=8048, debug=False):
|
|||||||
fallback_provider = ServiceProvider(config, mode="local")
|
fallback_provider = ServiceProvider(config, mode="local")
|
||||||
registry = UserRegistry(config.defaultdir)
|
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 = []
|
interceptors = []
|
||||||
if debug:
|
if debug:
|
||||||
interceptors.append(LoggingInterceptor())
|
interceptors.append(LoggingInterceptor())
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ class UserRegistry:
|
|||||||
def has_users(self) -> bool:
|
def has_users(self) -> bool:
|
||||||
"""Check if any users are registered (enables auth enforcement)."""
|
"""Check if any users are registered (enables auth enforcement)."""
|
||||||
return bool(self.user_service.list_users())
|
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):
|
def evict(self, username):
|
||||||
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
||||||
|
|||||||
@@ -301,7 +301,13 @@ message MCPRequest {
|
|||||||
|
|
||||||
service AuthService {
|
service AuthService {
|
||||||
rpc login (LoginRequest) returns (LoginResponse) {}
|
rpc login (LoginRequest) returns (LoginResponse) {}
|
||||||
|
rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
|
||||||
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
|
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 {
|
message LoginRequest {
|
||||||
@@ -309,6 +315,12 @@ message LoginRequest {
|
|||||||
string password = 2;
|
string password = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message LoginSSORequest {
|
||||||
|
string username = 1;
|
||||||
|
string id_token = 2;
|
||||||
|
string provider = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message LoginResponse {
|
message LoginResponse {
|
||||||
string token = 1;
|
string token = 1;
|
||||||
string username = 2;
|
string username = 2;
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class UserService:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
||||||
|
|
||||||
def generate_jwt(self, username) -> str:
|
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()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
raise ValueError(f"User '{username}' not found")
|
||||||
@@ -221,7 +221,8 @@ class UserService:
|
|||||||
"exp": expiration
|
"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):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
@@ -231,7 +232,8 @@ class UserService:
|
|||||||
"""Decodes JWT and returns username if token is valid and unexpired."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
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")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None
|
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
|
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
|
# 4. Logging in with new password must succeed
|
||||||
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
|
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
|
||||||
assert login_res_new.token is not None
|
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>
|
<dd>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</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>
|
<dt><code class="name"><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></dt>
|
||||||
<dd>
|
<dd>
|
||||||
<div class="desc"></div>
|
<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.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.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.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.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.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>
|
<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,
|
request_deserializer=connpy__pb2.LoginRequest.FromString,
|
||||||
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
|
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(
|
'change_password': grpc.unary_unary_rpc_method_handler(
|
||||||
servicer.change_password,
|
servicer.change_password,
|
||||||
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
|
||||||
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
|
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(
|
generic_handler = grpc.method_handlers_generic_handler(
|
||||||
'connpy.AuthService', rpc_method_handlers)
|
'connpy.AuthService', rpc_method_handlers)
|
||||||
@@ -1690,6 +1700,33 @@ def predict_execution_results(request,
|
|||||||
metadata,
|
metadata,
|
||||||
_registered_method=True)
|
_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
|
@staticmethod
|
||||||
def change_password(request,
|
def change_password(request,
|
||||||
target,
|
target,
|
||||||
@@ -1715,6 +1752,33 @@ def predict_execution_results(request,
|
|||||||
wait_for_ready,
|
wait_for_ready,
|
||||||
timeout,
|
timeout,
|
||||||
metadata,
|
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>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
@@ -1757,6 +1821,43 @@ def change_password(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</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">
|
<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>
|
<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>
|
</code></dt>
|
||||||
@@ -1794,6 +1895,43 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"></div>
|
<div class="desc"></div>
|
||||||
</dd>
|
</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>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
|
<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!')
|
context.set_details('Method not implemented!')
|
||||||
raise NotImplementedError('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):
|
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."""
|
"""Missing associated documentation comment in .proto file."""
|
||||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
context.set_details('Method not implemented!')
|
context.set_details('Method not implemented!')
|
||||||
@@ -1842,6 +1992,22 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</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">
|
<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>
|
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -1858,6 +2024,22 @@ def login(request,
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
|
||||||
</dd>
|
</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>
|
</dl>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
|
<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,
|
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
|
||||||
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
response_deserializer=connpy__pb2.LoginResponse.FromString,
|
||||||
_registered_method=True)
|
_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(
|
self.change_password = channel.unary_unary(
|
||||||
'/connpy.AuthService/change_password',
|
'/connpy.AuthService/change_password',
|
||||||
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
|
||||||
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
|
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>
|
_registered_method=True)</code></pre>
|
||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
|
<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>
|
<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="">
|
<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.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" 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<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>
|
<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="">
|
<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.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" 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ el.replaceWith(d);
|
|||||||
fallback_provider = ServiceProvider(config, mode="local")
|
fallback_provider = ServiceProvider(config, mode="local")
|
||||||
registry = UserRegistry(config.defaultdir)
|
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 = []
|
interceptors = []
|
||||||
if debug:
|
if debug:
|
||||||
interceptors.append(LoggingInterceptor())
|
interceptors.append(LoggingInterceptor())
|
||||||
@@ -487,7 +496,7 @@ def service(self):
|
|||||||
<span>Expand source code</span>
|
<span>Expand source code</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">class AuthInterceptor(grpc.ServerInterceptor):
|
<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):
|
def __init__(self, registry):
|
||||||
self.registry = 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")
|
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
|
||||||
|
|
||||||
token = self.registry.user_service.generate_jwt(username)
|
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(
|
return connpy_pb2.LoginResponse(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -682,6 +691,137 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
|
|||||||
expires_at=expires_at
|
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
|
@handle_errors
|
||||||
def change_password(self, request, context):
|
def change_password(self, request, context):
|
||||||
username = _current_user.get()
|
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>:
|
<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">
|
<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.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" 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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ el.replaceWith(d);
|
|||||||
def has_users(self) -> bool:
|
def has_users(self) -> bool:
|
||||||
"""Check if any users are registered (enables auth enforcement)."""
|
"""Check if any users are registered (enables auth enforcement)."""
|
||||||
return bool(self.user_service.list_users())
|
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):
|
def evict(self, username):
|
||||||
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
"""Remove and cleanly shut down cached provider (after delete or password change)."""
|
||||||
@@ -244,6 +250,22 @@ el.replaceWith(d);
|
|||||||
</details>
|
</details>
|
||||||
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
|
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
|
||||||
</dd>
|
</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">
|
<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>
|
<span>def <span class="ident">has_users</span></span>(<span>self) ‑> bool</span>
|
||||||
</code></dt>
|
</code></dt>
|
||||||
@@ -280,6 +302,7 @@ el.replaceWith(d);
|
|||||||
<ul class="">
|
<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.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_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>
|
<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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -125,6 +125,24 @@ conn ai
|
|||||||
# Run a command on all nodes in a folder
|
# Run a command on all nodes in a folder
|
||||||
conn run @office "uptime"
|
conn run @office "uptime"
|
||||||
</code></pre>
|
</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>
|
<hr>
|
||||||
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
|
<h2 id="plugin-requirements-for-connpy">Plugin Requirements for Connpy</h2>
|
||||||
<h3 id="remote-plugin-execution">Remote Plugin Execution</h3>
|
<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>
|
||||||
<li><a href="#usage">Usage</a><ul>
|
<li><a href="#usage">Usage</a><ul>
|
||||||
<li><a href="#basic-examples">Basic Examples:</a></li>
|
<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>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="#plugin-requirements-for-connpy">Plugin Requirements for Connpy</a><ul>
|
<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"))
|
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
|
||||||
|
|
||||||
def generate_jwt(self, username) -> str:
|
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()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
raise ValueError(f"User '{username}' not found")
|
||||||
@@ -267,7 +267,8 @@ el.replaceWith(d);
|
|||||||
"exp": expiration
|
"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):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
@@ -277,7 +278,8 @@ el.replaceWith(d);
|
|||||||
"""Decodes JWT and returns username if token is valid and unexpired."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
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")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None</code></pre>
|
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>
|
<span>Expand source code</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre><code class="python">def generate_jwt(self, username) -> str:
|
<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()
|
registry = self._load_registry()
|
||||||
if username not in registry["users"]:
|
if username not in registry["users"]:
|
||||||
raise ValueError(f"User '{username}' not found")
|
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
|
"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):
|
if isinstance(token, bytes):
|
||||||
token = token.decode("utf-8")
|
token = token.decode("utf-8")
|
||||||
|
|
||||||
return token</code></pre>
|
return token</code></pre>
|
||||||
</details>
|
</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>
|
</dd>
|
||||||
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
|
<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>
|
<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."""
|
"""Decodes JWT and returns username if token is valid and unexpired."""
|
||||||
registry = self._load_registry()
|
registry = self._load_registry()
|
||||||
try:
|
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")
|
return payload.get("sub")
|
||||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
|
||||||
return None</code></pre>
|
return None</code></pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user