kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__py3-none-any.whl
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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
kstlib/auth/config.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Configuration loading for the auth module.
|
|
2
|
+
|
|
3
|
+
This module provides helpers to load and parse auth configuration from
|
|
4
|
+
kstlib.conf.yml, following the config-driven pattern used by other modules.
|
|
5
|
+
|
|
6
|
+
Configuration hierarchy (lowest to highest priority):
|
|
7
|
+
1. Default values in kstlib.conf.yml
|
|
8
|
+
2. User config file overrides
|
|
9
|
+
3. Explicit constructor parameters
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from kstlib.auth.config import get_auth_config
|
|
13
|
+
>>> auth_config = get_auth_config()
|
|
14
|
+
>>> auth_config["token_storage"]
|
|
15
|
+
'memory'
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from kstlib.auth.errors import ConfigurationError
|
|
24
|
+
from kstlib.logging import TRACE_LEVEL, get_logger
|
|
25
|
+
from kstlib.utils.dict import deep_merge
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
|
|
30
|
+
from kstlib.auth.providers.base import AuthProviderConfig
|
|
31
|
+
from kstlib.auth.token import AbstractTokenStorage
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
# Default values (fallback when no config file is loaded)
|
|
37
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
DEFAULT_AUTH_CONFIG: dict[str, Any] = {
|
|
40
|
+
"default_provider": None,
|
|
41
|
+
"token_storage": "memory", # "memory", "file", or "sops"
|
|
42
|
+
"discovery_ttl": 3600,
|
|
43
|
+
"callback_server": {
|
|
44
|
+
"host": "127.0.0.1",
|
|
45
|
+
"port": 8400,
|
|
46
|
+
"port_range": None,
|
|
47
|
+
"timeout": 120,
|
|
48
|
+
},
|
|
49
|
+
"storage": {
|
|
50
|
+
"file": {
|
|
51
|
+
"directory": "~/.config/kstlib/auth/tokens",
|
|
52
|
+
},
|
|
53
|
+
"sops": {
|
|
54
|
+
"directory": "~/.config/kstlib/auth/tokens",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"status": {
|
|
58
|
+
"expiring_soon_threshold": 300, # seconds (5 min) - hard min: 60s
|
|
59
|
+
"refresh_expiring_soon_threshold": 600, # seconds (10 min) - hard min: 60s
|
|
60
|
+
"display_timezone": "local", # "local" or "utc"
|
|
61
|
+
},
|
|
62
|
+
"providers": {},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Hard limits for status display (defense in depth)
|
|
66
|
+
_STATUS_EXPIRING_SOON_MIN = 60 # Minimum threshold: 60 seconds
|
|
67
|
+
_STATUS_EXPIRING_SOON_MAX = 3600 # Maximum threshold: 1 hour (for access tokens)
|
|
68
|
+
_STATUS_REFRESH_EXPIRING_SOON_MAX = 172800 # Maximum threshold: 48 hours (for refresh tokens)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Config loading helpers
|
|
73
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_auth_config() -> dict[str, Any]:
|
|
77
|
+
"""Load the auth configuration section from global config.
|
|
78
|
+
|
|
79
|
+
Falls back to DEFAULT_AUTH_CONFIG if no config file is loaded or
|
|
80
|
+
the auth section is missing.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Auth configuration dictionary.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
>>> config = get_auth_config()
|
|
87
|
+
>>> config["token_storage"]
|
|
88
|
+
'memory'
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
from kstlib.config import get_config
|
|
92
|
+
from kstlib.config.exceptions import ConfigNotLoadedError
|
|
93
|
+
|
|
94
|
+
global_config = get_config()
|
|
95
|
+
auth_section = global_config.get("auth") if global_config else None # type: ignore[no-untyped-call]
|
|
96
|
+
|
|
97
|
+
if auth_section:
|
|
98
|
+
# Merge with defaults for missing keys
|
|
99
|
+
result = {**DEFAULT_AUTH_CONFIG}
|
|
100
|
+
deep_merge(result, dict(auth_section))
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
except (ConfigNotLoadedError, ImportError, FileNotFoundError):
|
|
104
|
+
logger.debug("No config file loaded, using auth defaults")
|
|
105
|
+
|
|
106
|
+
return dict(DEFAULT_AUTH_CONFIG)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_provider_config(
|
|
110
|
+
provider_name: str,
|
|
111
|
+
*,
|
|
112
|
+
config: Mapping[str, Any] | None = None,
|
|
113
|
+
) -> dict[str, Any] | None:
|
|
114
|
+
"""Get configuration for a specific auth provider.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
provider_name: Name of the provider to look up.
|
|
118
|
+
config: Optional explicit config dict (overrides global).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Provider configuration dict, or None if not found.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
>>> cfg = get_provider_config("nonexistent")
|
|
125
|
+
>>> cfg is None
|
|
126
|
+
True
|
|
127
|
+
"""
|
|
128
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
129
|
+
providers = auth_config.get("providers", {})
|
|
130
|
+
|
|
131
|
+
if isinstance(providers, dict):
|
|
132
|
+
return dict(providers.get(provider_name, {})) or None
|
|
133
|
+
|
|
134
|
+
# Handle legacy list format (unlikely but defensive)
|
|
135
|
+
if isinstance(providers, list):
|
|
136
|
+
for p in providers:
|
|
137
|
+
if isinstance(p, dict) and p.get("name") == provider_name:
|
|
138
|
+
return dict(p)
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_callback_server_config(
|
|
144
|
+
*,
|
|
145
|
+
config: Mapping[str, Any] | None = None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Get callback server configuration.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
config: Optional explicit config dict.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Callback server configuration with defaults applied.
|
|
154
|
+
"""
|
|
155
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
156
|
+
callback_cfg = auth_config.get("callback_server", {})
|
|
157
|
+
defaults = DEFAULT_AUTH_CONFIG["callback_server"]
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"host": callback_cfg.get("host", defaults["host"]),
|
|
161
|
+
"port": callback_cfg.get("port", defaults["port"]),
|
|
162
|
+
"port_range": callback_cfg.get("port_range", defaults["port_range"]),
|
|
163
|
+
"timeout": callback_cfg.get("timeout", defaults["timeout"]),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_status_config(
|
|
168
|
+
*,
|
|
169
|
+
config: Mapping[str, Any] | None = None,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Get status display configuration with hard limits enforced.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
config: Optional explicit config dict.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Status configuration with validated values.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
>>> cfg = get_status_config()
|
|
181
|
+
>>> cfg["expiring_soon_threshold"]
|
|
182
|
+
120
|
|
183
|
+
>>> cfg["display_timezone"]
|
|
184
|
+
'local'
|
|
185
|
+
"""
|
|
186
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
187
|
+
status_cfg = auth_config.get("status", {})
|
|
188
|
+
defaults = DEFAULT_AUTH_CONFIG["status"]
|
|
189
|
+
|
|
190
|
+
# Get access token threshold with hard limits
|
|
191
|
+
threshold = status_cfg.get("expiring_soon_threshold", defaults["expiring_soon_threshold"])
|
|
192
|
+
threshold = max(_STATUS_EXPIRING_SOON_MIN, min(_STATUS_EXPIRING_SOON_MAX, int(threshold)))
|
|
193
|
+
|
|
194
|
+
# Get refresh token threshold with hard limits (higher max since refresh tokens live longer)
|
|
195
|
+
refresh_threshold = status_cfg.get(
|
|
196
|
+
"refresh_expiring_soon_threshold",
|
|
197
|
+
defaults["refresh_expiring_soon_threshold"],
|
|
198
|
+
)
|
|
199
|
+
refresh_threshold = max(
|
|
200
|
+
_STATUS_EXPIRING_SOON_MIN,
|
|
201
|
+
min(_STATUS_REFRESH_EXPIRING_SOON_MAX, int(refresh_threshold)),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Get timezone (validate allowed values)
|
|
205
|
+
tz_display = status_cfg.get("display_timezone", defaults["display_timezone"])
|
|
206
|
+
if tz_display not in ("local", "utc"):
|
|
207
|
+
tz_display = "local"
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"expiring_soon_threshold": threshold,
|
|
211
|
+
"refresh_expiring_soon_threshold": refresh_threshold,
|
|
212
|
+
"display_timezone": tz_display,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_token_storage_from_config(
|
|
217
|
+
*,
|
|
218
|
+
storage_type: str | None = None,
|
|
219
|
+
provider_name: str | None = None,
|
|
220
|
+
config: Mapping[str, Any] | None = None,
|
|
221
|
+
) -> AbstractTokenStorage:
|
|
222
|
+
"""Create a token storage instance based on configuration.
|
|
223
|
+
|
|
224
|
+
Priority for storage_type:
|
|
225
|
+
1. Explicit storage_type parameter
|
|
226
|
+
2. Provider-specific token_storage setting
|
|
227
|
+
3. Global auth.token_storage setting
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
storage_type: Explicit storage type override.
|
|
231
|
+
provider_name: Provider name to check for specific settings.
|
|
232
|
+
config: Optional explicit config dict.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Configured token storage instance.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ConfigurationError: If storage type is invalid.
|
|
239
|
+
"""
|
|
240
|
+
from kstlib.auth.token import get_token_storage
|
|
241
|
+
|
|
242
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
243
|
+
|
|
244
|
+
# Determine storage type
|
|
245
|
+
resolved_type = storage_type
|
|
246
|
+
|
|
247
|
+
if resolved_type is None and provider_name:
|
|
248
|
+
provider_cfg = get_provider_config(provider_name, config=auth_config)
|
|
249
|
+
if provider_cfg:
|
|
250
|
+
resolved_type = provider_cfg.get("token_storage")
|
|
251
|
+
|
|
252
|
+
if resolved_type is None:
|
|
253
|
+
resolved_type = auth_config.get("token_storage", "memory")
|
|
254
|
+
|
|
255
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
256
|
+
logger.log(
|
|
257
|
+
TRACE_LEVEL,
|
|
258
|
+
"[CONFIG] Token storage type resolved: %s (provider=%s)",
|
|
259
|
+
resolved_type,
|
|
260
|
+
provider_name or "global",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Get storage-specific settings
|
|
264
|
+
storage_settings = auth_config.get("storage", {})
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
if resolved_type == "memory":
|
|
268
|
+
return get_token_storage("memory")
|
|
269
|
+
|
|
270
|
+
if resolved_type == "file":
|
|
271
|
+
file_cfg = storage_settings.get("file", {})
|
|
272
|
+
directory = file_cfg.get("directory", DEFAULT_AUTH_CONFIG["storage"]["file"]["directory"])
|
|
273
|
+
directory = Path(directory).expanduser()
|
|
274
|
+
return get_token_storage("file", directory=directory)
|
|
275
|
+
|
|
276
|
+
if resolved_type == "sops":
|
|
277
|
+
sops_cfg = storage_settings.get("sops", {})
|
|
278
|
+
directory = Path(sops_cfg.get("directory", DEFAULT_AUTH_CONFIG["storage"]["sops"]["directory"]))
|
|
279
|
+
directory = directory.expanduser()
|
|
280
|
+
return get_token_storage("sops", directory=directory)
|
|
281
|
+
|
|
282
|
+
msg = f"Unknown token storage type: {resolved_type}. Use 'memory', 'file', or 'sops'."
|
|
283
|
+
raise ConfigurationError(msg)
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
if isinstance(e, ConfigurationError):
|
|
287
|
+
raise
|
|
288
|
+
msg = f"Failed to create token storage '{resolved_type}': {e}"
|
|
289
|
+
raise ConfigurationError(msg) from e
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
293
|
+
# AuthProviderConfig builder
|
|
294
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def build_provider_config(
|
|
298
|
+
provider_name: str,
|
|
299
|
+
*,
|
|
300
|
+
config: Mapping[str, Any] | None = None,
|
|
301
|
+
**overrides: Any,
|
|
302
|
+
) -> AuthProviderConfig:
|
|
303
|
+
"""Build an AuthProviderConfig from configuration.
|
|
304
|
+
|
|
305
|
+
Merges global defaults, provider-specific config, and explicit overrides.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
provider_name: Name of the provider in config.
|
|
309
|
+
config: Optional explicit config dict.
|
|
310
|
+
**overrides: Direct overrides (highest priority).
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Configured AuthProviderConfig instance.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
ConfigurationError: If required fields are missing.
|
|
317
|
+
|
|
318
|
+
Example:
|
|
319
|
+
>>> cfg = build_provider_config("keycloak", client_id="my-app") # doctest: +SKIP
|
|
320
|
+
"""
|
|
321
|
+
from kstlib.auth.providers.base import AuthProviderConfig
|
|
322
|
+
|
|
323
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
324
|
+
provider_cfg = get_provider_config(provider_name, config=auth_config) or {}
|
|
325
|
+
callback_cfg = get_callback_server_config(config=auth_config)
|
|
326
|
+
|
|
327
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
328
|
+
logger.log(
|
|
329
|
+
TRACE_LEVEL,
|
|
330
|
+
"[CONFIG] Building provider config for '%s' | overrides=%s",
|
|
331
|
+
provider_name,
|
|
332
|
+
list(overrides) if overrides else [],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Merge: provider config < overrides
|
|
336
|
+
merged = {**provider_cfg, **overrides}
|
|
337
|
+
|
|
338
|
+
# Validate required fields
|
|
339
|
+
if not merged.get("client_id"):
|
|
340
|
+
raise ConfigurationError(f"Provider '{provider_name}' missing required 'client_id'")
|
|
341
|
+
|
|
342
|
+
# Resolve client_secret (may be SOPS reference)
|
|
343
|
+
client_secret = merged.get("client_secret")
|
|
344
|
+
if client_secret and isinstance(client_secret, str) and client_secret.startswith("sops://"):
|
|
345
|
+
client_secret = _resolve_sops_secret(client_secret)
|
|
346
|
+
|
|
347
|
+
# Determine endpoints (OIDC issuer or explicit OAuth2 URLs)
|
|
348
|
+
issuer = merged.get("issuer")
|
|
349
|
+
authorize_url = merged.get("authorization_endpoint") or merged.get("authorize_url")
|
|
350
|
+
token_url = merged.get("token_endpoint") or merged.get("token_url")
|
|
351
|
+
|
|
352
|
+
if not issuer and not (authorize_url and token_url):
|
|
353
|
+
raise ConfigurationError(
|
|
354
|
+
f"Provider '{provider_name}' requires either 'issuer' (OIDC) or "
|
|
355
|
+
f"both 'authorization_endpoint' and 'token_endpoint' (OAuth2)"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Normalize scopes: ensure it's always a list (YAML may parse as string)
|
|
359
|
+
scopes_raw = merged.get("scopes", ["openid", "profile", "email"])
|
|
360
|
+
if isinstance(scopes_raw, str):
|
|
361
|
+
# Single scope as string, or space-separated scopes
|
|
362
|
+
scopes = scopes_raw.split() if " " in scopes_raw else [scopes_raw]
|
|
363
|
+
else:
|
|
364
|
+
scopes = list(scopes_raw) if scopes_raw else ["openid", "profile", "email"]
|
|
365
|
+
|
|
366
|
+
# Normalize redirect_uri: ensure it's a string (YAML may parse as list by mistake)
|
|
367
|
+
redirect_uri_raw = merged.get("redirect_uri")
|
|
368
|
+
if isinstance(redirect_uri_raw, list | tuple):
|
|
369
|
+
redirect_uri = str(redirect_uri_raw[0]) if redirect_uri_raw else None
|
|
370
|
+
elif redirect_uri_raw:
|
|
371
|
+
redirect_uri = str(redirect_uri_raw)
|
|
372
|
+
else:
|
|
373
|
+
redirect_uri = None
|
|
374
|
+
|
|
375
|
+
return AuthProviderConfig(
|
|
376
|
+
client_id=merged["client_id"],
|
|
377
|
+
client_secret=client_secret,
|
|
378
|
+
issuer=issuer,
|
|
379
|
+
authorize_url=authorize_url,
|
|
380
|
+
token_url=token_url,
|
|
381
|
+
revoke_url=merged.get("revocation_endpoint") or merged.get("revoke_url"),
|
|
382
|
+
userinfo_url=merged.get("userinfo_endpoint") or merged.get("userinfo_url"),
|
|
383
|
+
jwks_uri=merged.get("jwks_uri"),
|
|
384
|
+
end_session_endpoint=merged.get("end_session_endpoint"),
|
|
385
|
+
scopes=scopes,
|
|
386
|
+
redirect_uri=redirect_uri or f"http://{callback_cfg['host']}:{callback_cfg['port']}/callback",
|
|
387
|
+
pkce=merged.get("pkce", True),
|
|
388
|
+
discovery_ttl=merged.get("discovery_ttl", auth_config.get("discovery_ttl", 3600)),
|
|
389
|
+
headers=merged.get("headers", {}),
|
|
390
|
+
# SSL/TLS options
|
|
391
|
+
ssl_verify=merged.get("ssl_verify", True),
|
|
392
|
+
ssl_ca_bundle=merged.get("ssl_ca_bundle"),
|
|
393
|
+
extra=merged.get("extra", {}),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
398
|
+
# Utility functions
|
|
399
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _resolve_sops_secret(sops_uri: str) -> str | None:
|
|
403
|
+
"""Resolve a SOPS secret reference.
|
|
404
|
+
|
|
405
|
+
Format: sops://path/to/file.yaml#key.path
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
sops_uri: SOPS URI to resolve.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Resolved secret value, or None if resolution fails.
|
|
412
|
+
"""
|
|
413
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
414
|
+
# Log path but not the key (could reveal structure)
|
|
415
|
+
safe_uri = sops_uri.split("#")[0] if "#" in sops_uri else sops_uri
|
|
416
|
+
logger.log(TRACE_LEVEL, "[CONFIG] Resolving SOPS secret: %s", safe_uri)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
from kstlib.secrets import resolve_secret
|
|
420
|
+
|
|
421
|
+
# Parse sops://path#key format
|
|
422
|
+
if not sops_uri.startswith("sops://"):
|
|
423
|
+
return sops_uri
|
|
424
|
+
|
|
425
|
+
remainder = sops_uri[7:] # Remove "sops://"
|
|
426
|
+
if "#" in remainder:
|
|
427
|
+
path, key = remainder.rsplit("#", 1)
|
|
428
|
+
else:
|
|
429
|
+
path, key = remainder, None
|
|
430
|
+
|
|
431
|
+
# Resolve via secrets module
|
|
432
|
+
result = resolve_secret(f"sops:{path}", key=key)
|
|
433
|
+
|
|
434
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
435
|
+
logger.log(TRACE_LEVEL, "[CONFIG] SOPS secret resolved successfully")
|
|
436
|
+
|
|
437
|
+
return str(result) if result else None
|
|
438
|
+
|
|
439
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
440
|
+
# Graceful fallback for secret resolution
|
|
441
|
+
logger.warning("Failed to resolve SOPS secret '%s': %s", sops_uri, e)
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def list_configured_providers(
|
|
446
|
+
*,
|
|
447
|
+
config: Mapping[str, Any] | None = None,
|
|
448
|
+
) -> list[str]:
|
|
449
|
+
"""List all configured provider names.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
config: Optional explicit config dict.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
List of provider names.
|
|
456
|
+
"""
|
|
457
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
458
|
+
providers = auth_config.get("providers", {})
|
|
459
|
+
|
|
460
|
+
if isinstance(providers, dict):
|
|
461
|
+
return list(providers)
|
|
462
|
+
|
|
463
|
+
# Legacy list format
|
|
464
|
+
if isinstance(providers, list):
|
|
465
|
+
names: list[str] = []
|
|
466
|
+
for p in providers:
|
|
467
|
+
if isinstance(p, dict):
|
|
468
|
+
name = p.get("name")
|
|
469
|
+
if name and isinstance(name, str):
|
|
470
|
+
names.append(name)
|
|
471
|
+
return names
|
|
472
|
+
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def get_default_provider_name(
|
|
477
|
+
*,
|
|
478
|
+
config: Mapping[str, Any] | None = None,
|
|
479
|
+
) -> str | None:
|
|
480
|
+
"""Get the default provider name from config.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
config: Optional explicit config dict.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Default provider name, or None if not set.
|
|
487
|
+
"""
|
|
488
|
+
auth_config = dict(config) if config else get_auth_config()
|
|
489
|
+
return auth_config.get("default_provider")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
__all__ = [
|
|
493
|
+
"DEFAULT_AUTH_CONFIG",
|
|
494
|
+
"build_provider_config",
|
|
495
|
+
"get_auth_config",
|
|
496
|
+
"get_callback_server_config",
|
|
497
|
+
"get_default_provider_name",
|
|
498
|
+
"get_provider_config",
|
|
499
|
+
"get_status_config",
|
|
500
|
+
"get_token_storage_from_config",
|
|
501
|
+
"list_configured_providers",
|
|
502
|
+
]
|
kstlib/auth/errors.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Authentication module exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from kstlib.config.exceptions import KstlibError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(KstlibError):
|
|
11
|
+
"""Base exception for all authentication errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.details = details or {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigurationError(AuthError):
|
|
20
|
+
"""Raised when auth configuration is invalid or missing."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderNotFoundError(AuthError):
|
|
24
|
+
"""Raised when a named provider is not configured."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, provider_name: str) -> None:
|
|
27
|
+
super().__init__(f"Provider '{provider_name}' not found in configuration")
|
|
28
|
+
self.provider_name = provider_name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiscoveryError(AuthError):
|
|
32
|
+
"""Raised when OIDC discovery fails."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, issuer: str, reason: str) -> None:
|
|
35
|
+
super().__init__(f"Discovery failed for '{issuer}': {reason}")
|
|
36
|
+
self.issuer = issuer
|
|
37
|
+
self.reason = reason
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenError(AuthError):
|
|
41
|
+
"""Base exception for token-related errors."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TokenExpiredError(TokenError):
|
|
45
|
+
"""Raised when a token has expired and cannot be refreshed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TokenRefreshError(TokenError):
|
|
49
|
+
"""Raised when token refresh fails."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, reason: str, *, retryable: bool = False) -> None:
|
|
52
|
+
super().__init__(f"Token refresh failed: {reason}")
|
|
53
|
+
self.reason = reason
|
|
54
|
+
self.retryable = retryable
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TokenExchangeError(TokenError):
|
|
58
|
+
"""Raised when authorization code exchange fails."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, reason: str, *, error_code: str | None = None) -> None:
|
|
61
|
+
super().__init__(f"Token exchange failed: {reason}")
|
|
62
|
+
self.reason = reason
|
|
63
|
+
self.error_code = error_code
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TokenValidationError(TokenError):
|
|
67
|
+
"""Raised when JWT validation fails (signature, claims, expiry)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, reason: str, *, claim: str | None = None) -> None:
|
|
70
|
+
super().__init__(f"Token validation failed: {reason}")
|
|
71
|
+
self.reason = reason
|
|
72
|
+
self.claim = claim
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TokenStorageError(TokenError):
|
|
76
|
+
"""Raised when token persistence fails (save/load/delete)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AuthorizationError(AuthError):
|
|
80
|
+
"""Raised during authorization flow failures."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
reason: str,
|
|
85
|
+
*,
|
|
86
|
+
error_code: str | None = None,
|
|
87
|
+
error_description: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
super().__init__(f"Authorization failed: {reason}")
|
|
90
|
+
self.reason = reason
|
|
91
|
+
self.error_code = error_code
|
|
92
|
+
self.error_description = error_description
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CallbackServerError(AuthError):
|
|
96
|
+
"""Raised when the local callback server fails to start or receive callback."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, reason: str, *, port: int | None = None) -> None:
|
|
99
|
+
super().__init__(f"Callback server error: {reason}")
|
|
100
|
+
self.reason = reason
|
|
101
|
+
self.port = port
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PreflightError(AuthError):
|
|
105
|
+
"""Raised when preflight validation fails."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, step: str, reason: str) -> None:
|
|
108
|
+
super().__init__(f"Preflight failed at '{step}': {reason}")
|
|
109
|
+
self.step = step
|
|
110
|
+
self.reason = reason
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = [
|
|
114
|
+
"AuthError",
|
|
115
|
+
"AuthorizationError",
|
|
116
|
+
"CallbackServerError",
|
|
117
|
+
"ConfigurationError",
|
|
118
|
+
"DiscoveryError",
|
|
119
|
+
"PreflightError",
|
|
120
|
+
"ProviderNotFoundError",
|
|
121
|
+
"TokenError",
|
|
122
|
+
"TokenExchangeError",
|
|
123
|
+
"TokenExpiredError",
|
|
124
|
+
"TokenRefreshError",
|
|
125
|
+
"TokenStorageError",
|
|
126
|
+
"TokenValidationError",
|
|
127
|
+
]
|