sso-cli 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sso_cli-1.0.0/PKG-INFO +85 -0
- sso_cli-1.0.0/README.md +59 -0
- sso_cli-1.0.0/pyproject.toml +44 -0
- sso_cli-1.0.0/setup.cfg +4 -0
- sso_cli-1.0.0/sso_cli/__init__.py +8 -0
- sso_cli-1.0.0/sso_cli/auth.py +319 -0
- sso_cli-1.0.0/sso_cli/cli.py +333 -0
- sso_cli-1.0.0/sso_cli/config.py +59 -0
- sso_cli-1.0.0/sso_cli/secrets.py +58 -0
- sso_cli-1.0.0/sso_cli/utils.py +12 -0
- sso_cli-1.0.0/sso_cli.egg-info/PKG-INFO +85 -0
- sso_cli-1.0.0/sso_cli.egg-info/SOURCES.txt +14 -0
- sso_cli-1.0.0/sso_cli.egg-info/dependency_links.txt +1 -0
- sso_cli-1.0.0/sso_cli.egg-info/entry_points.txt +2 -0
- sso_cli-1.0.0/sso_cli.egg-info/requires.txt +6 -0
- sso_cli-1.0.0/sso_cli.egg-info/top_level.txt +1 -0
sso_cli-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sso-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SSO auth CLI - Keycloak/OIDC token & roles
|
|
5
|
+
Author: SSO CLI Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: sso,auth,keycloak,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: httpx>=0.24.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
22
|
+
Requires-Dist: pyperclip>=1.8.2
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: inquirer>=3.0.0
|
|
25
|
+
Requires-Dist: keyring>=24.0.0
|
|
26
|
+
|
|
27
|
+
# sso-cli
|
|
28
|
+
|
|
29
|
+
SSO auth CLI - Keycloak/OIDC tokens & roles.
|
|
30
|
+
|
|
31
|
+
CLI tool for authenticating with Keycloak/OIDC providers, fetching access tokens, and listing user roles. Supports multiple environments, password and client credentials authentication, with secrets stored in the system keyring. With prefix matching for quick access and organized role display from both JWT tokens and userinfo endpoints.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install sso-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Get token
|
|
43
|
+
sso prod user@example.com
|
|
44
|
+
|
|
45
|
+
# Prefix matching (auto-complete)
|
|
46
|
+
sso p u # → sso prod user@example.com (if unique)
|
|
47
|
+
sso prod u # → error if ambiguous
|
|
48
|
+
|
|
49
|
+
# List roles (JWT + UserInfo)
|
|
50
|
+
sso prod user@example.com -r
|
|
51
|
+
|
|
52
|
+
# List/Remove
|
|
53
|
+
sso -l env
|
|
54
|
+
sso -l user
|
|
55
|
+
sso -d env prod
|
|
56
|
+
sso -d user prod user@example.com
|
|
57
|
+
|
|
58
|
+
# Interactive
|
|
59
|
+
sso
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Config
|
|
63
|
+
|
|
64
|
+
Configuration is stored in `~/sso_config.yaml` (auto-created by the app). All setup is done through the interactive flow - no manual YAML editing required.
|
|
65
|
+
|
|
66
|
+
Example structure (for reference):
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
environments:
|
|
70
|
+
prod:
|
|
71
|
+
sso_url: https://sso.example.com
|
|
72
|
+
realm: Production
|
|
73
|
+
client_id: my-client-id # optional, prompted if needed
|
|
74
|
+
|
|
75
|
+
users:
|
|
76
|
+
prod:
|
|
77
|
+
user@example.com:
|
|
78
|
+
auth_type: password
|
|
79
|
+
email: user@example.com
|
|
80
|
+
api-client:
|
|
81
|
+
auth_type: client_credentials
|
|
82
|
+
client_id: api-client
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Secrets (passwords and client secrets) are stored securely in the system keyring. Environments and users are automatically created on first use through an interactive setup flow. The tool supports optional client_id configuration per environment for Keycloak instances that require it.
|
sso_cli-1.0.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# sso-cli
|
|
2
|
+
|
|
3
|
+
SSO auth CLI - Keycloak/OIDC tokens & roles.
|
|
4
|
+
|
|
5
|
+
CLI tool for authenticating with Keycloak/OIDC providers, fetching access tokens, and listing user roles. Supports multiple environments, password and client credentials authentication, with secrets stored in the system keyring. With prefix matching for quick access and organized role display from both JWT tokens and userinfo endpoints.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sso-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Get token
|
|
17
|
+
sso prod user@example.com
|
|
18
|
+
|
|
19
|
+
# Prefix matching (auto-complete)
|
|
20
|
+
sso p u # → sso prod user@example.com (if unique)
|
|
21
|
+
sso prod u # → error if ambiguous
|
|
22
|
+
|
|
23
|
+
# List roles (JWT + UserInfo)
|
|
24
|
+
sso prod user@example.com -r
|
|
25
|
+
|
|
26
|
+
# List/Remove
|
|
27
|
+
sso -l env
|
|
28
|
+
sso -l user
|
|
29
|
+
sso -d env prod
|
|
30
|
+
sso -d user prod user@example.com
|
|
31
|
+
|
|
32
|
+
# Interactive
|
|
33
|
+
sso
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Config
|
|
37
|
+
|
|
38
|
+
Configuration is stored in `~/sso_config.yaml` (auto-created by the app). All setup is done through the interactive flow - no manual YAML editing required.
|
|
39
|
+
|
|
40
|
+
Example structure (for reference):
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
environments:
|
|
44
|
+
prod:
|
|
45
|
+
sso_url: https://sso.example.com
|
|
46
|
+
realm: Production
|
|
47
|
+
client_id: my-client-id # optional, prompted if needed
|
|
48
|
+
|
|
49
|
+
users:
|
|
50
|
+
prod:
|
|
51
|
+
user@example.com:
|
|
52
|
+
auth_type: password
|
|
53
|
+
email: user@example.com
|
|
54
|
+
api-client:
|
|
55
|
+
auth_type: client_credentials
|
|
56
|
+
client_id: api-client
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Secrets (passwords and client secrets) are stored securely in the system keyring. Environments and users are automatically created on first use through an interactive setup flow. The tool supports optional client_id configuration per environment for Keycloak instances that require it.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sso-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SSO auth CLI - Keycloak/OIDC token & roles"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "SSO CLI Contributors"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["sso", "auth", "keycloak", "cli"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.7",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.24.0",
|
|
30
|
+
"pyyaml>=6.0.0",
|
|
31
|
+
"pyperclip>=1.8.2",
|
|
32
|
+
"rich>=13.0.0",
|
|
33
|
+
"inquirer>=3.0.0",
|
|
34
|
+
"keyring>=24.0.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
sso = "sso_cli.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
packages = ["sso_cli"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
sso_cli = ["py.typed"]
|
sso_cli-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""SSO authentication"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import httpx
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Dict, Any, Optional, List, Tuple
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
|
|
12
|
+
from .config import SSOConfigManager
|
|
13
|
+
from .secrets import SecretManager
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SSOAuthenticator:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.config = {}
|
|
22
|
+
self.config_manager = SSOConfigManager()
|
|
23
|
+
self.load_config()
|
|
24
|
+
|
|
25
|
+
def load_config(self):
|
|
26
|
+
self.config = self.config_manager.load()
|
|
27
|
+
if not self.config:
|
|
28
|
+
self.config = {"environments": {}, "users": {}}
|
|
29
|
+
if "environments" not in self.config:
|
|
30
|
+
self.config["environments"] = {}
|
|
31
|
+
if "users" not in self.config:
|
|
32
|
+
self.config["users"] = {}
|
|
33
|
+
|
|
34
|
+
def save_config(self) -> bool:
|
|
35
|
+
return self.config_manager.save(self.config)
|
|
36
|
+
|
|
37
|
+
def get_environments(self) -> Dict[str, Dict[str, str]]:
|
|
38
|
+
return self.config.get("environments", {})
|
|
39
|
+
|
|
40
|
+
def get_environment(self, env_key: str) -> Optional[Dict[str, str]]:
|
|
41
|
+
return self.get_environments().get(env_key)
|
|
42
|
+
|
|
43
|
+
def create_environment(self, env_key: str = None) -> Dict[str, str]:
|
|
44
|
+
if not env_key:
|
|
45
|
+
env_key = Prompt.ask("Environment key")
|
|
46
|
+
console.print(f"[dim]Environment '{env_key}' not found. Creating...[/dim]\n")
|
|
47
|
+
sso_domain = Prompt.ask("SSO domain", default=f"sso.{env_key}.com")
|
|
48
|
+
realm = Prompt.ask("Realm", default="master")
|
|
49
|
+
client_id = Prompt.ask("Client ID (optional)", default="").strip()
|
|
50
|
+
|
|
51
|
+
domain = sso_domain.strip()
|
|
52
|
+
if domain.startswith('https://'):
|
|
53
|
+
domain = domain[8:]
|
|
54
|
+
elif domain.startswith('http://'):
|
|
55
|
+
domain = domain[7:]
|
|
56
|
+
domain = domain.rstrip('/')
|
|
57
|
+
sso_url = f"https://{domain}"
|
|
58
|
+
|
|
59
|
+
env_config = {"sso_url": sso_url, "realm": realm}
|
|
60
|
+
if client_id:
|
|
61
|
+
env_config["client_id"] = client_id
|
|
62
|
+
if "environments" not in self.config:
|
|
63
|
+
self.config["environments"] = {}
|
|
64
|
+
self.config["environments"][env_key] = env_config
|
|
65
|
+
self.save_config()
|
|
66
|
+
console.print(f"[green]Environment '{env_key}' created[/green]")
|
|
67
|
+
return env_config
|
|
68
|
+
|
|
69
|
+
def get_sso_url(self, env_key: str = None) -> str:
|
|
70
|
+
if not env_key:
|
|
71
|
+
env_key = Prompt.ask("Environment key")
|
|
72
|
+
env = self.get_environment(env_key)
|
|
73
|
+
if not env:
|
|
74
|
+
env = self.create_environment(env_key)
|
|
75
|
+
return f"{env['sso_url']}/realms/{env['realm']}"
|
|
76
|
+
|
|
77
|
+
def remove_environment(self, env_key: str) -> bool:
|
|
78
|
+
if "environments" not in self.config:
|
|
79
|
+
return False
|
|
80
|
+
if env_key not in self.config["environments"]:
|
|
81
|
+
return False
|
|
82
|
+
if "users" in self.config and env_key in self.config["users"]:
|
|
83
|
+
for user_key in list(self.config["users"][env_key].keys()):
|
|
84
|
+
SecretManager.delete_secret(env_key, user_key, "password")
|
|
85
|
+
SecretManager.delete_secret(env_key, user_key, "client_secret")
|
|
86
|
+
del self.config["users"][env_key]
|
|
87
|
+
del self.config["environments"][env_key]
|
|
88
|
+
self.save_config()
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
def remove_user(self, environment: str, user_key: str) -> bool:
|
|
92
|
+
if "users" not in self.config:
|
|
93
|
+
return False
|
|
94
|
+
if environment not in self.config["users"]:
|
|
95
|
+
return False
|
|
96
|
+
if user_key not in self.config["users"][environment]:
|
|
97
|
+
return False
|
|
98
|
+
del self.config["users"][environment][user_key]
|
|
99
|
+
SecretManager.delete_secret(environment, user_key, "password")
|
|
100
|
+
SecretManager.delete_secret(environment, user_key, "client_secret")
|
|
101
|
+
self.save_config()
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def get_available_users_for_environment(self, environment: str) -> Dict[str, Dict[str, str]]:
|
|
105
|
+
return self.config.get("users", {}).get(environment, {})
|
|
106
|
+
|
|
107
|
+
def find_user_by_key(self, environment: str, user_key: str) -> Optional[str]:
|
|
108
|
+
users = self.get_available_users_for_environment(environment)
|
|
109
|
+
if user_key in users:
|
|
110
|
+
return user_key
|
|
111
|
+
for key, config in users.items():
|
|
112
|
+
email = config.get("email", "")
|
|
113
|
+
if email and email.split("@")[0] == user_key:
|
|
114
|
+
return key
|
|
115
|
+
|
|
116
|
+
matches = []
|
|
117
|
+
for key, config in users.items():
|
|
118
|
+
email = config.get("email", "")
|
|
119
|
+
client_id = config.get("client_id", "")
|
|
120
|
+
if (key.startswith(user_key) or
|
|
121
|
+
(email and email.split("@")[0].startswith(user_key)) or
|
|
122
|
+
(client_id and client_id.startswith(user_key))):
|
|
123
|
+
display = email or client_id or key
|
|
124
|
+
matches.append((key, display))
|
|
125
|
+
|
|
126
|
+
if len(matches) == 1:
|
|
127
|
+
return matches[0][0]
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def find_users_by_prefix(self, environment: str, prefix: str) -> List[Tuple[str, str]]:
|
|
131
|
+
users = self.get_available_users_for_environment(environment)
|
|
132
|
+
matches = []
|
|
133
|
+
for key, config in users.items():
|
|
134
|
+
email = config.get("email", "")
|
|
135
|
+
client_id = config.get("client_id", "")
|
|
136
|
+
display = email or client_id or key
|
|
137
|
+
|
|
138
|
+
if (key.startswith(prefix) or
|
|
139
|
+
(email and email.split("@")[0].startswith(prefix)) or
|
|
140
|
+
(client_id and client_id.startswith(prefix))):
|
|
141
|
+
matches.append((key, display))
|
|
142
|
+
return matches
|
|
143
|
+
|
|
144
|
+
def find_environment_by_prefix(self, prefix: str) -> Optional[str]:
|
|
145
|
+
envs = self.get_environments()
|
|
146
|
+
matches = [key for key in envs.keys() if key.startswith(prefix)]
|
|
147
|
+
if len(matches) == 1:
|
|
148
|
+
return matches[0]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def find_environments_by_prefix(self, prefix: str) -> List[str]:
|
|
152
|
+
envs = self.get_environments()
|
|
153
|
+
return [key for key in envs.keys() if key.startswith(prefix)]
|
|
154
|
+
|
|
155
|
+
def get_user_credentials(self, environment: str, user_key: str) -> tuple[str, str, str]:
|
|
156
|
+
if "users" not in self.config:
|
|
157
|
+
self.config["users"] = {}
|
|
158
|
+
if environment not in self.config["users"]:
|
|
159
|
+
self.config["users"][environment] = {}
|
|
160
|
+
|
|
161
|
+
actual_key = self.find_user_by_key(environment, user_key)
|
|
162
|
+
if not actual_key:
|
|
163
|
+
actual_key = user_key
|
|
164
|
+
|
|
165
|
+
if actual_key not in self.config["users"][environment]:
|
|
166
|
+
if "@" in actual_key:
|
|
167
|
+
self.config["users"][environment][actual_key] = {
|
|
168
|
+
"auth_type": "password",
|
|
169
|
+
"email": actual_key
|
|
170
|
+
}
|
|
171
|
+
password = getpass(f"Password for {actual_key}: ")
|
|
172
|
+
SecretManager.set_secret(environment, actual_key, "password", password)
|
|
173
|
+
console.print("[green]Saved to keyring[/green]")
|
|
174
|
+
else:
|
|
175
|
+
self.config["users"][environment][actual_key] = {
|
|
176
|
+
"auth_type": "client_credentials",
|
|
177
|
+
"client_id": actual_key
|
|
178
|
+
}
|
|
179
|
+
client_secret = getpass(f"Client Secret for {actual_key}: ")
|
|
180
|
+
SecretManager.set_secret(environment, actual_key, "client_secret", client_secret)
|
|
181
|
+
console.print("[green]Saved to keyring[/green]")
|
|
182
|
+
self.save_config()
|
|
183
|
+
|
|
184
|
+
user_config = self.config["users"][environment][actual_key]
|
|
185
|
+
auth_type = user_config.get("auth_type", "password")
|
|
186
|
+
|
|
187
|
+
if auth_type == "client_credentials":
|
|
188
|
+
client_id = user_config.get("client_id")
|
|
189
|
+
if not client_id:
|
|
190
|
+
raise ValueError(f"Missing client_id for {actual_key}")
|
|
191
|
+
client_secret = SecretManager.get_secret(environment, actual_key, "client_secret")
|
|
192
|
+
if not client_secret:
|
|
193
|
+
client_secret = getpass(f"Client Secret for {client_id}: ")
|
|
194
|
+
SecretManager.set_secret(environment, actual_key, "client_secret", client_secret)
|
|
195
|
+
console.print("[green]Saved to keyring[/green]")
|
|
196
|
+
return client_id, client_secret, auth_type
|
|
197
|
+
else:
|
|
198
|
+
email = user_config.get("email")
|
|
199
|
+
if not email:
|
|
200
|
+
raise ValueError(f"Missing email for {actual_key}")
|
|
201
|
+
password = SecretManager.get_secret(environment, actual_key, "password")
|
|
202
|
+
if not password:
|
|
203
|
+
password = getpass(f"Password for {email}: ")
|
|
204
|
+
SecretManager.set_secret(environment, actual_key, "password", password)
|
|
205
|
+
console.print("[green]Saved to keyring[/green]")
|
|
206
|
+
return email, password, auth_type
|
|
207
|
+
|
|
208
|
+
async def get_token(self, environment: str, user_key: str) -> str:
|
|
209
|
+
sso_url = self.get_sso_url(environment)
|
|
210
|
+
cred1, cred2, auth_type = self.get_user_credentials(environment, user_key)
|
|
211
|
+
token_url = f"{sso_url}/protocol/openid-connect/token"
|
|
212
|
+
env_config = self.get_environment(environment) or {}
|
|
213
|
+
client_id = env_config.get("client_id")
|
|
214
|
+
|
|
215
|
+
if auth_type == "client_credentials":
|
|
216
|
+
data = {"grant_type": "client_credentials", "client_id": cred1, "client_secret": cred2}
|
|
217
|
+
else:
|
|
218
|
+
data = {"grant_type": "password", "username": cred1, "password": cred2}
|
|
219
|
+
if client_id:
|
|
220
|
+
data["client_id"] = client_id
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
async with httpx.AsyncClient() as client:
|
|
224
|
+
response = await client.post(token_url, data=data)
|
|
225
|
+
if response.status_code == 401:
|
|
226
|
+
if auth_type == "password":
|
|
227
|
+
if not client_id:
|
|
228
|
+
console.print("[yellow]This environment may require a client_id.[/yellow]")
|
|
229
|
+
new_client_id = Prompt.ask("Client ID (or press Enter to skip)", default="").strip()
|
|
230
|
+
if new_client_id:
|
|
231
|
+
env_config = self.get_environment(environment) or {}
|
|
232
|
+
env_config["client_id"] = new_client_id
|
|
233
|
+
if "environments" not in self.config:
|
|
234
|
+
self.config["environments"] = {}
|
|
235
|
+
self.config["environments"][environment] = env_config
|
|
236
|
+
self.save_config()
|
|
237
|
+
client_id = new_client_id
|
|
238
|
+
console.print("[green]Client ID saved[/green]")
|
|
239
|
+
|
|
240
|
+
console.print("[yellow]Auth failed. Enter new password (or press Enter to retry with same):[/yellow]")
|
|
241
|
+
new_password = getpass(f"Password for {cred1}: ")
|
|
242
|
+
if new_password:
|
|
243
|
+
SecretManager.set_secret(environment, user_key, "password", new_password)
|
|
244
|
+
console.print("[green]Saved to keyring[/green]")
|
|
245
|
+
data = {"grant_type": "password", "username": cred1, "password": new_password}
|
|
246
|
+
else:
|
|
247
|
+
data = {"grant_type": "password", "username": cred1, "password": cred2}
|
|
248
|
+
if client_id:
|
|
249
|
+
data["client_id"] = client_id
|
|
250
|
+
else:
|
|
251
|
+
console.print("[yellow]Auth failed. Enter new client secret (or press Enter to retry with same):[/yellow]")
|
|
252
|
+
new_secret = getpass(f"Client Secret for {cred1}: ")
|
|
253
|
+
if new_secret:
|
|
254
|
+
SecretManager.set_secret(environment, user_key, "client_secret", new_secret)
|
|
255
|
+
console.print("[green]Saved to keyring[/green]")
|
|
256
|
+
data = {"grant_type": "client_credentials", "client_id": cred1, "client_secret": new_secret}
|
|
257
|
+
else:
|
|
258
|
+
data = {"grant_type": "client_credentials", "client_id": cred1, "client_secret": cred2}
|
|
259
|
+
async with httpx.AsyncClient() as retry_client:
|
|
260
|
+
retry_response = await retry_client.post(token_url, data=data)
|
|
261
|
+
retry_response.raise_for_status()
|
|
262
|
+
return retry_response.json()["access_token"]
|
|
263
|
+
response.raise_for_status()
|
|
264
|
+
return response.json()["access_token"]
|
|
265
|
+
except httpx.HTTPStatusError as e:
|
|
266
|
+
if e.response.status_code == 401:
|
|
267
|
+
raise ValueError("Authentication failed")
|
|
268
|
+
raise
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"Error: {e}")
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
def _decode_jwt_payload(self, token: str) -> Dict[str, Any]:
|
|
274
|
+
try:
|
|
275
|
+
parts = token.split('.')
|
|
276
|
+
if len(parts) != 3:
|
|
277
|
+
return {}
|
|
278
|
+
payload = parts[1]
|
|
279
|
+
payload += '=' * (4 - len(payload) % 4) if len(payload) % 4 else ''
|
|
280
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
281
|
+
return json.loads(decoded)
|
|
282
|
+
except Exception:
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
def _extract_roles_from_payload(self, payload: Dict[str, Any]) -> List[str]:
|
|
286
|
+
roles = []
|
|
287
|
+
if "realm_access" in payload and isinstance(payload["realm_access"], dict):
|
|
288
|
+
if "roles" in payload["realm_access"]:
|
|
289
|
+
roles.extend(payload["realm_access"]["roles"])
|
|
290
|
+
if "resource_access" in payload and isinstance(payload["resource_access"], dict):
|
|
291
|
+
for resource, access in payload["resource_access"].items():
|
|
292
|
+
if isinstance(access, dict) and "roles" in access:
|
|
293
|
+
for role in access["roles"]:
|
|
294
|
+
roles.append(f"{resource}:{role}")
|
|
295
|
+
return sorted(roles)
|
|
296
|
+
|
|
297
|
+
async def get_user_roles(self, environment: str, user_key: str) -> Dict[str, List[str]]:
|
|
298
|
+
token = await self.get_token(environment, user_key)
|
|
299
|
+
jwt_payload = self._decode_jwt_payload(token)
|
|
300
|
+
jwt_roles = self._extract_roles_from_payload(jwt_payload)
|
|
301
|
+
|
|
302
|
+
sso_url = self.get_sso_url(environment)
|
|
303
|
+
userinfo_url = f"{sso_url}/protocol/openid-connect/userinfo"
|
|
304
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
305
|
+
userinfo_roles = []
|
|
306
|
+
try:
|
|
307
|
+
async with httpx.AsyncClient() as client:
|
|
308
|
+
response = await client.get(userinfo_url, headers=headers)
|
|
309
|
+
response.raise_for_status()
|
|
310
|
+
userinfo = response.json()
|
|
311
|
+
userinfo_roles = self._extract_roles_from_payload(userinfo)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Error fetching userinfo: {e}")
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"jwt": jwt_roles,
|
|
317
|
+
"userinfo": userinfo_roles
|
|
318
|
+
}
|
|
319
|
+
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""CLI interface"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import asyncio
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
from typing import List
|
|
8
|
+
import inquirer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from .auth import SSOAuthenticator
|
|
14
|
+
from .utils import copy_to_clipboard
|
|
15
|
+
|
|
16
|
+
logging.basicConfig(level=logging.WARNING)
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModernSelector:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.console = Console()
|
|
25
|
+
|
|
26
|
+
def select_from_list(self, title: str, options: List[str]) -> int:
|
|
27
|
+
questions = [inquirer.List('choice', message=title, choices=options, carousel=True)]
|
|
28
|
+
try:
|
|
29
|
+
answers = inquirer.prompt(questions)
|
|
30
|
+
if answers is None:
|
|
31
|
+
self.console.print("\n[yellow]Exiting...[/yellow]")
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
return options.index(answers['choice'])
|
|
34
|
+
except (KeyboardInterrupt, EOFError):
|
|
35
|
+
self.console.print("\n[yellow]Exiting...[/yellow]")
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def show_help():
|
|
40
|
+
console.print()
|
|
41
|
+
console.print("[bold green]SSO CLI[/bold green]\n")
|
|
42
|
+
console.print("[bold]Commands:[/bold]")
|
|
43
|
+
cmd1 = Text(" sso ")
|
|
44
|
+
cmd1.append("[env]", style="dim")
|
|
45
|
+
cmd1.append(" ")
|
|
46
|
+
cmd1.append("[user]", style="dim")
|
|
47
|
+
console.print(cmd1)
|
|
48
|
+
cmd2 = Text(" sso ")
|
|
49
|
+
cmd2.append("[env]", style="dim")
|
|
50
|
+
cmd2.append(" ")
|
|
51
|
+
cmd2.append("[user]", style="dim")
|
|
52
|
+
cmd2.append(" -r, --roles")
|
|
53
|
+
console.print(cmd2)
|
|
54
|
+
console.print(" sso (interactive)")
|
|
55
|
+
console.print("\n[bold]List:[/bold]")
|
|
56
|
+
console.print(" sso -l, --list env")
|
|
57
|
+
console.print(" sso -l, --list user")
|
|
58
|
+
console.print("\n[bold]Remove:[/bold]")
|
|
59
|
+
cmd3 = Text(" sso -d, --remove env ")
|
|
60
|
+
cmd3.append("[env]", style="dim")
|
|
61
|
+
console.print(cmd3)
|
|
62
|
+
cmd4 = Text(" sso -d, --remove user ")
|
|
63
|
+
cmd4.append("[env]", style="dim")
|
|
64
|
+
cmd4.append(" ")
|
|
65
|
+
cmd4.append("[user]", style="dim")
|
|
66
|
+
console.print(cmd4)
|
|
67
|
+
console.print("\n[bold]Options:[/bold]")
|
|
68
|
+
console.print(" -h, --help Show help")
|
|
69
|
+
console.print()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def get_token_non_interactive(environment: str, user: str) -> str:
|
|
73
|
+
authenticator = SSOAuthenticator()
|
|
74
|
+
return await authenticator.get_token(environment, user)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def get_user_roles_non_interactive(environment: str, user: str):
|
|
78
|
+
authenticator = SSOAuthenticator()
|
|
79
|
+
return await authenticator.get_user_roles(environment, user)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_environment(authenticator: SSOAuthenticator, env_arg: str) -> str:
|
|
83
|
+
env_key = authenticator.find_environment_by_prefix(env_arg)
|
|
84
|
+
if env_key:
|
|
85
|
+
return env_key
|
|
86
|
+
env_matches = authenticator.find_environments_by_prefix(env_arg)
|
|
87
|
+
if len(env_matches) > 1:
|
|
88
|
+
console.print(f"[red]Ambiguous environment '{env_arg}':[/red]")
|
|
89
|
+
for match in env_matches:
|
|
90
|
+
console.print(f" {match}")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
return env_matches[0] if env_matches else env_arg
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_user(authenticator: SSOAuthenticator, env_key: str, user_arg: str) -> str:
|
|
96
|
+
user_key = authenticator.find_user_by_key(env_key, user_arg)
|
|
97
|
+
if user_key:
|
|
98
|
+
return user_key
|
|
99
|
+
user_matches = authenticator.find_users_by_prefix(env_key, user_arg)
|
|
100
|
+
if len(user_matches) > 1:
|
|
101
|
+
console.print(f"[red]Ambiguous user '{user_arg}' in '{env_key}':[/red]")
|
|
102
|
+
for key, display in user_matches:
|
|
103
|
+
console.print(f" {display} ({key})")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
return user_matches[0][0] if user_matches else user_arg
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def show_roles(authenticator: SSOAuthenticator, env_key: str, user_key: str):
|
|
109
|
+
console.print(f"[yellow]Fetching roles...[/yellow]")
|
|
110
|
+
try:
|
|
111
|
+
roles_data = await authenticator.get_user_roles(env_key, user_key)
|
|
112
|
+
console.print()
|
|
113
|
+
console.print("[bold green]Roles[/bold green]\n")
|
|
114
|
+
if roles_data["jwt"]:
|
|
115
|
+
console.print("[bold]JWT Token:[/bold]")
|
|
116
|
+
console.print("\n".join([f" • {r}" for r in roles_data["jwt"]]))
|
|
117
|
+
if roles_data["userinfo"]:
|
|
118
|
+
if roles_data["jwt"]:
|
|
119
|
+
console.print()
|
|
120
|
+
console.print("[bold]UserInfo Endpoint:[/bold]")
|
|
121
|
+
console.print("\n".join([f" • {r}" for r in roles_data["userinfo"]]))
|
|
122
|
+
if not roles_data["jwt"] and not roles_data["userinfo"]:
|
|
123
|
+
console.print("[yellow]No roles[/yellow]")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def show_token(authenticator: SSOAuthenticator, env_key: str, user_key: str):
|
|
130
|
+
console.print(f"[yellow]Authenticating...[/yellow]")
|
|
131
|
+
try:
|
|
132
|
+
token = await authenticator.get_token(env_key, user_key)
|
|
133
|
+
console.print()
|
|
134
|
+
if copy_to_clipboard(token):
|
|
135
|
+
console.print("[bold green]Token copied![/bold green]")
|
|
136
|
+
else:
|
|
137
|
+
console.print(f"[bold]Token:[/bold]\n{token}")
|
|
138
|
+
except Exception as e:
|
|
139
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def main_async():
|
|
144
|
+
parser = argparse.ArgumentParser(description="SSO CLI", add_help=False)
|
|
145
|
+
parser.add_argument('environment', nargs='?', help='Environment')
|
|
146
|
+
parser.add_argument('user', nargs='?', help='User')
|
|
147
|
+
parser.add_argument('-r', '--roles', action='store_true', help='List roles')
|
|
148
|
+
parser.add_argument('-l', '--list', metavar='TYPE', help='List: env/envs or user/users')
|
|
149
|
+
parser.add_argument('-d', '--remove', nargs='+', metavar=('TYPE', 'ID'), help='Remove: env <id> or user <env> <user>')
|
|
150
|
+
parser.add_argument('-h', '--help', action='store_true', help='Show help')
|
|
151
|
+
|
|
152
|
+
args = parser.parse_args()
|
|
153
|
+
|
|
154
|
+
if args.help:
|
|
155
|
+
show_help()
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if args.list:
|
|
159
|
+
list_type = args.list.lower()
|
|
160
|
+
authenticator = SSOAuthenticator()
|
|
161
|
+
|
|
162
|
+
if list_type in ['env', 'envs', 'environment', 'environments']:
|
|
163
|
+
envs = authenticator.get_environments()
|
|
164
|
+
if not envs:
|
|
165
|
+
console.print("[dim]No environments configured[/dim]")
|
|
166
|
+
else:
|
|
167
|
+
for env_key, config in envs.items():
|
|
168
|
+
sso_url = config.get("sso_url", "")
|
|
169
|
+
realm = config.get("realm", "")
|
|
170
|
+
console.print(f"{env_key}: {sso_url}/realms/{realm}")
|
|
171
|
+
|
|
172
|
+
elif list_type in ['user', 'users']:
|
|
173
|
+
all_users = authenticator.config.get("users", {})
|
|
174
|
+
if not all_users:
|
|
175
|
+
console.print("[dim]No users configured[/dim]")
|
|
176
|
+
else:
|
|
177
|
+
for env_key, users in all_users.items():
|
|
178
|
+
if users:
|
|
179
|
+
console.print(f"\n{env_key}:")
|
|
180
|
+
for user_key, config in users.items():
|
|
181
|
+
email = config.get("email")
|
|
182
|
+
client_id = config.get("client_id")
|
|
183
|
+
auth_type = config.get("auth_type", "password")
|
|
184
|
+
display = email or client_id or user_key
|
|
185
|
+
console.print(f" {user_key}: {display} ({auth_type})")
|
|
186
|
+
else:
|
|
187
|
+
console.print(f"[red]Invalid list type: {args.list}[/red]")
|
|
188
|
+
console.print("Use: env/envs or user/users")
|
|
189
|
+
sys.exit(1)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if args.remove:
|
|
193
|
+
if len(args.remove) < 2:
|
|
194
|
+
console.print("[red]Usage: sso -d <type> <id>[/red]")
|
|
195
|
+
console.print(" sso -d env <env_id>")
|
|
196
|
+
console.print(" sso -d user <env_id> <user_id>")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
|
|
199
|
+
remove_type = args.remove[0].lower()
|
|
200
|
+
authenticator = SSOAuthenticator()
|
|
201
|
+
|
|
202
|
+
if remove_type in ['env', 'envs', 'environment', 'environments']:
|
|
203
|
+
env_id = args.remove[1]
|
|
204
|
+
if authenticator.remove_environment(env_id):
|
|
205
|
+
console.print(f"[green]Environment '{env_id}' removed[/green]")
|
|
206
|
+
else:
|
|
207
|
+
console.print(f"[red]Environment '{env_id}' not found[/red]")
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
elif remove_type in ['user', 'users']:
|
|
211
|
+
if len(args.remove) < 3:
|
|
212
|
+
console.print("[red]Usage: sso -d user <env_id> <user_id>[/red]")
|
|
213
|
+
sys.exit(1)
|
|
214
|
+
env_id = args.remove[1]
|
|
215
|
+
user_id = args.remove[2]
|
|
216
|
+
actual_key = authenticator.find_user_by_key(env_id, user_id)
|
|
217
|
+
if not actual_key:
|
|
218
|
+
actual_key = user_id
|
|
219
|
+
if authenticator.remove_user(env_id, actual_key):
|
|
220
|
+
console.print(f"[green]User '{actual_key}' removed from '{env_id}'[/green]")
|
|
221
|
+
else:
|
|
222
|
+
console.print(f"[red]User '{user_id}' not found in '{env_id}'[/red]")
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
else:
|
|
225
|
+
console.print(f"[red]Invalid remove type: {remove_type}[/red]")
|
|
226
|
+
console.print("Use: env/envs or user/users")
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if args.environment and args.user:
|
|
231
|
+
authenticator = SSOAuthenticator()
|
|
232
|
+
env_key = resolve_environment(authenticator, args.environment)
|
|
233
|
+
user_key = resolve_user(authenticator, env_key, args.user)
|
|
234
|
+
try:
|
|
235
|
+
if args.roles:
|
|
236
|
+
roles_data = await get_user_roles_non_interactive(env_key, user_key)
|
|
237
|
+
if roles_data["jwt"]:
|
|
238
|
+
print("JWT Token:")
|
|
239
|
+
for role in roles_data["jwt"]:
|
|
240
|
+
print(f" {role}")
|
|
241
|
+
if roles_data["userinfo"]:
|
|
242
|
+
if roles_data["jwt"]:
|
|
243
|
+
print()
|
|
244
|
+
print("UserInfo Endpoint:")
|
|
245
|
+
for role in roles_data["userinfo"]:
|
|
246
|
+
print(f" {role}")
|
|
247
|
+
else:
|
|
248
|
+
token = await get_token_non_interactive(env_key, user_key)
|
|
249
|
+
print(token)
|
|
250
|
+
return
|
|
251
|
+
except Exception as e:
|
|
252
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
|
|
255
|
+
if args.environment and not args.user:
|
|
256
|
+
console.print()
|
|
257
|
+
console.print("[bold green]SSO CLI[/bold green]\n")
|
|
258
|
+
authenticator = SSOAuthenticator()
|
|
259
|
+
env_key = resolve_environment(authenticator, args.environment)
|
|
260
|
+
if env_key not in authenticator.get_environments():
|
|
261
|
+
authenticator.create_environment(env_key)
|
|
262
|
+
env_users = authenticator.get_available_users_for_environment(env_key)
|
|
263
|
+
if not env_users:
|
|
264
|
+
console.print("[dim]No users. Creating first user...[/dim]\n")
|
|
265
|
+
selected_user = Prompt.ask("User key")
|
|
266
|
+
else:
|
|
267
|
+
selector = ModernSelector()
|
|
268
|
+
user_options = []
|
|
269
|
+
for user_key, config in env_users.items():
|
|
270
|
+
display = config.get("email") or config.get("client_id") or user_key
|
|
271
|
+
user_options.append(display)
|
|
272
|
+
user_choice = selector.select_from_list("User", user_options)
|
|
273
|
+
selected_user = list(env_users.keys())[user_choice]
|
|
274
|
+
if args.roles:
|
|
275
|
+
await show_roles(authenticator, env_key, selected_user)
|
|
276
|
+
else:
|
|
277
|
+
await show_token(authenticator, env_key, selected_user)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if not args.environment and not args.user:
|
|
281
|
+
console.print()
|
|
282
|
+
console.print("[bold green]SSO CLI[/bold green]\n")
|
|
283
|
+
authenticator = SSOAuthenticator()
|
|
284
|
+
selector = ModernSelector()
|
|
285
|
+
envs = authenticator.get_environments()
|
|
286
|
+
|
|
287
|
+
if not envs:
|
|
288
|
+
console.print("[dim]First use: creating environment...[/dim]\n")
|
|
289
|
+
env_key = Prompt.ask("Environment key", default=args.environment if args.environment else None)
|
|
290
|
+
authenticator.create_environment(env_key)
|
|
291
|
+
selected_env = env_key
|
|
292
|
+
else:
|
|
293
|
+
if args.environment:
|
|
294
|
+
selected_env = resolve_environment(authenticator, args.environment)
|
|
295
|
+
if selected_env not in envs:
|
|
296
|
+
authenticator.create_environment(selected_env)
|
|
297
|
+
else:
|
|
298
|
+
env_options = [env_key for env_key in envs.keys()]
|
|
299
|
+
env_choice = selector.select_from_list("Environment", env_options)
|
|
300
|
+
selected_env = list(envs.keys())[env_choice]
|
|
301
|
+
|
|
302
|
+
env_users = authenticator.get_available_users_for_environment(selected_env)
|
|
303
|
+
if not env_users:
|
|
304
|
+
console.print("[dim]No users. Creating first user...[/dim]\n")
|
|
305
|
+
selected_user = Prompt.ask("User key", default=args.user if args.user else None)
|
|
306
|
+
else:
|
|
307
|
+
if args.user:
|
|
308
|
+
selected_user = resolve_user(authenticator, selected_env, args.user)
|
|
309
|
+
else:
|
|
310
|
+
user_options = []
|
|
311
|
+
for user_key, config in env_users.items():
|
|
312
|
+
display = config.get("email") or config.get("client_id") or user_key
|
|
313
|
+
user_options.append(display)
|
|
314
|
+
user_choice = selector.select_from_list("User", user_options)
|
|
315
|
+
selected_user = list(env_users.keys())[user_choice]
|
|
316
|
+
|
|
317
|
+
if args.roles:
|
|
318
|
+
await show_roles(authenticator, selected_env, selected_user)
|
|
319
|
+
else:
|
|
320
|
+
await show_token(authenticator, selected_env, selected_user)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
print("Error: Need both env and user", file=sys.stderr)
|
|
324
|
+
sys.exit(1)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main():
|
|
328
|
+
try:
|
|
329
|
+
asyncio.run(main_async())
|
|
330
|
+
except KeyboardInterrupt:
|
|
331
|
+
console.print("\n[yellow]Goodbye[/yellow]")
|
|
332
|
+
sys.exit(0)
|
|
333
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Config management"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import yaml
|
|
10
|
+
YAML_AVAILABLE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
YAML_AVAILABLE = False
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SSOConfigManager:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.config_path = self._find_config_path()
|
|
20
|
+
|
|
21
|
+
def _find_config_path(self) -> Optional[Path]:
|
|
22
|
+
paths = []
|
|
23
|
+
if "SSO_CONFIG_PATH" in os.environ:
|
|
24
|
+
paths.append(Path(os.environ["SSO_CONFIG_PATH"]))
|
|
25
|
+
paths.extend([
|
|
26
|
+
Path.cwd() / "sso_config.yaml",
|
|
27
|
+
Path.home() / "sso_config.yaml",
|
|
28
|
+
Path(__file__).parent.parent / "sso_config.yaml"
|
|
29
|
+
])
|
|
30
|
+
for path in paths:
|
|
31
|
+
if path.exists():
|
|
32
|
+
return path
|
|
33
|
+
return Path.home() / "sso_config.yaml"
|
|
34
|
+
|
|
35
|
+
def load(self) -> Dict[str, Any]:
|
|
36
|
+
if not YAML_AVAILABLE:
|
|
37
|
+
raise ImportError("PyYAML required: pip install pyyaml")
|
|
38
|
+
if not self.config_path or not self.config_path.exists():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
42
|
+
return yaml.safe_load(f) or {}
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Failed to load config: {e}")
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
def save(self, config: Dict[str, Any]) -> bool:
|
|
48
|
+
if not YAML_AVAILABLE:
|
|
49
|
+
raise ImportError("PyYAML required: pip install pyyaml")
|
|
50
|
+
try:
|
|
51
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
with open(self.config_path, 'w', encoding='utf-8') as f:
|
|
53
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
54
|
+
os.chmod(self.config_path, 0o600)
|
|
55
|
+
return True
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"Failed to save config: {e}")
|
|
58
|
+
return False
|
|
59
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Secret management via keyring"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import keyring
|
|
7
|
+
KEYRING_AVAILABLE = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
KEYRING_AVAILABLE = False
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SecretManager:
|
|
15
|
+
SERVICE_NAME = "sso-cli"
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def get_secret_key(environment: str, user_key: str, secret_type: str) -> str:
|
|
19
|
+
return f"{environment}:{user_key}:{secret_type}"
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def get_secret(environment: str, user_key: str, secret_type: str):
|
|
23
|
+
if not KEYRING_AVAILABLE:
|
|
24
|
+
raise ImportError("keyring required: pip install keyring")
|
|
25
|
+
try:
|
|
26
|
+
key = SecretManager.get_secret_key(environment, user_key, secret_type)
|
|
27
|
+
return keyring.get_password(SecretManager.SERVICE_NAME, key)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.error(f"Keyring error: {e}")
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def set_secret(environment: str, user_key: str, secret_type: str, secret: str) -> bool:
|
|
34
|
+
if not KEYRING_AVAILABLE:
|
|
35
|
+
raise ImportError("keyring required: pip install keyring")
|
|
36
|
+
try:
|
|
37
|
+
key = SecretManager.get_secret_key(environment, user_key, secret_type)
|
|
38
|
+
keyring.set_password(SecretManager.SERVICE_NAME, key, secret)
|
|
39
|
+
return True
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error(f"Keyring error: {e}")
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def delete_secret(environment: str, user_key: str, secret_type: str) -> bool:
|
|
46
|
+
if not KEYRING_AVAILABLE:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
key = SecretManager.get_secret_key(environment, user_key, secret_type)
|
|
50
|
+
keyring.delete_password(SecretManager.SERVICE_NAME, key)
|
|
51
|
+
return True
|
|
52
|
+
except Exception as e:
|
|
53
|
+
error_str = str(e).lower()
|
|
54
|
+
if "not found" in error_str or "item not found" in error_str or "-25300" in error_str:
|
|
55
|
+
return True
|
|
56
|
+
logger.error(f"Keyring error: {e}")
|
|
57
|
+
return False
|
|
58
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sso-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SSO auth CLI - Keycloak/OIDC token & roles
|
|
5
|
+
Author: SSO CLI Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: sso,auth,keycloak,cli
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: httpx>=0.24.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
22
|
+
Requires-Dist: pyperclip>=1.8.2
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: inquirer>=3.0.0
|
|
25
|
+
Requires-Dist: keyring>=24.0.0
|
|
26
|
+
|
|
27
|
+
# sso-cli
|
|
28
|
+
|
|
29
|
+
SSO auth CLI - Keycloak/OIDC tokens & roles.
|
|
30
|
+
|
|
31
|
+
CLI tool for authenticating with Keycloak/OIDC providers, fetching access tokens, and listing user roles. Supports multiple environments, password and client credentials authentication, with secrets stored in the system keyring. With prefix matching for quick access and organized role display from both JWT tokens and userinfo endpoints.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install sso-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Get token
|
|
43
|
+
sso prod user@example.com
|
|
44
|
+
|
|
45
|
+
# Prefix matching (auto-complete)
|
|
46
|
+
sso p u # → sso prod user@example.com (if unique)
|
|
47
|
+
sso prod u # → error if ambiguous
|
|
48
|
+
|
|
49
|
+
# List roles (JWT + UserInfo)
|
|
50
|
+
sso prod user@example.com -r
|
|
51
|
+
|
|
52
|
+
# List/Remove
|
|
53
|
+
sso -l env
|
|
54
|
+
sso -l user
|
|
55
|
+
sso -d env prod
|
|
56
|
+
sso -d user prod user@example.com
|
|
57
|
+
|
|
58
|
+
# Interactive
|
|
59
|
+
sso
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Config
|
|
63
|
+
|
|
64
|
+
Configuration is stored in `~/sso_config.yaml` (auto-created by the app). All setup is done through the interactive flow - no manual YAML editing required.
|
|
65
|
+
|
|
66
|
+
Example structure (for reference):
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
environments:
|
|
70
|
+
prod:
|
|
71
|
+
sso_url: https://sso.example.com
|
|
72
|
+
realm: Production
|
|
73
|
+
client_id: my-client-id # optional, prompted if needed
|
|
74
|
+
|
|
75
|
+
users:
|
|
76
|
+
prod:
|
|
77
|
+
user@example.com:
|
|
78
|
+
auth_type: password
|
|
79
|
+
email: user@example.com
|
|
80
|
+
api-client:
|
|
81
|
+
auth_type: client_credentials
|
|
82
|
+
client_id: api-client
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Secrets (passwords and client secrets) are stored securely in the system keyring. Environments and users are automatically created on first use through an interactive setup flow. The tool supports optional client_id configuration per environment for Keycloak instances that require it.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sso_cli/__init__.py
|
|
4
|
+
sso_cli/auth.py
|
|
5
|
+
sso_cli/cli.py
|
|
6
|
+
sso_cli/config.py
|
|
7
|
+
sso_cli/secrets.py
|
|
8
|
+
sso_cli/utils.py
|
|
9
|
+
sso_cli.egg-info/PKG-INFO
|
|
10
|
+
sso_cli.egg-info/SOURCES.txt
|
|
11
|
+
sso_cli.egg-info/dependency_links.txt
|
|
12
|
+
sso_cli.egg-info/entry_points.txt
|
|
13
|
+
sso_cli.egg-info/requires.txt
|
|
14
|
+
sso_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sso_cli
|