feat(auth,cli): add SSO/OIDC authentication and provider management

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