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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.1.dist-info/METADATA +201 -0
  159. kstlib-1.0.1.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.1.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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
+ ]