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 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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """SSO CLI - Keycloak auth tool"""
2
+
3
+ from .auth import SSOAuthenticator
4
+ from .config import SSOConfigManager
5
+ from .secrets import SecretManager
6
+
7
+ __all__ = ["SSOAuthenticator", "SSOConfigManager", "SecretManager"]
8
+
@@ -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,12 @@
1
+ """Utilities"""
2
+
3
+ import pyperclip
4
+
5
+
6
+ def copy_to_clipboard(text: str) -> bool:
7
+ try:
8
+ pyperclip.copy(text)
9
+ return True
10
+ except Exception:
11
+ return False
12
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ sso = sso_cli.cli:main
@@ -0,0 +1,6 @@
1
+ httpx>=0.24.0
2
+ pyyaml>=6.0.0
3
+ pyperclip>=1.8.2
4
+ rich>=13.0.0
5
+ inquirer>=3.0.0
6
+ keyring>=24.0.0
@@ -0,0 +1 @@
1
+ sso_cli