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
@@ -0,0 +1,821 @@
1
+ """OIDC provider with PKCE support and automatic discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import httpx
13
+
14
+ from kstlib.auth.errors import (
15
+ ConfigurationError,
16
+ DiscoveryError,
17
+ TokenExchangeError,
18
+ TokenValidationError,
19
+ )
20
+ from kstlib.auth.models import (
21
+ AuthFlow,
22
+ PreflightReport,
23
+ PreflightResult,
24
+ PreflightStatus,
25
+ Token,
26
+ )
27
+ from kstlib.auth.providers.base import load_provider_from_config
28
+ from kstlib.auth.providers.oauth2 import OAuth2Provider
29
+ from kstlib.logging import TRACE_LEVEL, get_logger
30
+
31
+ if TYPE_CHECKING:
32
+ from kstlib.auth.providers.base import AuthProviderConfig
33
+ from kstlib.auth.token import AbstractTokenStorage
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class OIDCProvider(OAuth2Provider):
39
+ """OpenID Connect provider with PKCE and automatic discovery.
40
+
41
+ Extends OAuth2Provider with:
42
+ - Automatic discovery of endpoints via .well-known/openid-configuration
43
+ - PKCE (Proof Key for Code Exchange) for enhanced security
44
+ - ID token validation (signature, claims)
45
+ - UserInfo endpoint support
46
+
47
+ Example:
48
+ >>> from kstlib.auth.providers import OIDCProvider, AuthProviderConfig # doctest: +SKIP
49
+ >>> from kstlib.auth.token import MemoryTokenStorage # doctest: +SKIP
50
+ >>>
51
+ >>> config = AuthProviderConfig( # doctest: +SKIP
52
+ ... client_id="my-app",
53
+ ... issuer="https://auth.example.com",
54
+ ... scopes=["openid", "profile", "email"],
55
+ ... pkce=True, # Enabled by default
56
+ ... )
57
+ >>> provider = OIDCProvider("example", config, MemoryTokenStorage()) # doctest: +SKIP
58
+ >>> url, state = provider.get_authorization_url() # doctest: +SKIP
59
+ >>> # User authenticates, provider.exchange_code() handles PKCE automatically
60
+
61
+ Config-driven usage:
62
+ >>> # Configure in kstlib.conf.yml:
63
+ >>> # auth:
64
+ >>> # providers:
65
+ >>> # corporate:
66
+ >>> # type: oidc
67
+ >>> # issuer: https://idp.corp.local/realms/main
68
+ >>> # client_id: my-app
69
+ >>> # scopes: [openid, profile, email]
70
+ >>> # pkce: true
71
+ >>> provider = OIDCProvider.from_config("corporate") # doctest: +SKIP
72
+ """
73
+
74
+ @classmethod
75
+ def from_config(
76
+ cls,
77
+ provider_name: str,
78
+ *,
79
+ config: dict[str, Any] | None = None,
80
+ http_client: httpx.Client | None = None,
81
+ **overrides: Any,
82
+ ) -> OIDCProvider:
83
+ """Create an OIDCProvider from configuration.
84
+
85
+ Loads provider settings from kstlib.conf.yml (auth.providers section)
86
+ and creates a fully configured provider instance.
87
+
88
+ Args:
89
+ provider_name: Name of the provider in config (e.g., "corporate").
90
+ config: Optional explicit config dict (overrides global config).
91
+ http_client: Optional custom HTTP client.
92
+ **overrides: Direct parameter overrides (highest priority).
93
+
94
+ Returns:
95
+ Configured OIDCProvider instance.
96
+
97
+ Raises:
98
+ ConfigurationError: If provider not found or required fields missing.
99
+
100
+ Example:
101
+ >>> provider = OIDCProvider.from_config("corporate") # doctest: +SKIP
102
+ >>> provider = OIDCProvider.from_config(
103
+ ... "corporate",
104
+ ... client_id="override-id", # Override config value
105
+ ... ) # doctest: +SKIP
106
+ """
107
+ auth_config, token_storage = load_provider_from_config(
108
+ provider_name,
109
+ allowed_types=("oidc", "openid", "openidconnect"),
110
+ type_label="oidc",
111
+ config=config,
112
+ **overrides,
113
+ )
114
+
115
+ return cls(
116
+ name=provider_name,
117
+ config=auth_config,
118
+ token_storage=token_storage,
119
+ http_client=http_client,
120
+ )
121
+
122
+ def __init__(
123
+ self,
124
+ name: str,
125
+ config: AuthProviderConfig,
126
+ token_storage: AbstractTokenStorage,
127
+ *,
128
+ http_client: httpx.Client | None = None,
129
+ ) -> None:
130
+ """Initialize OIDC provider.
131
+
132
+ Supports three configuration modes:
133
+
134
+ 1. **Auto discovery**: Only ``issuer`` provided. Endpoints discovered via
135
+ ``.well-known/openid-configuration``.
136
+
137
+ 2. **Hybrid mode**: ``issuer`` + some explicit endpoints. Discovery fills
138
+ missing endpoints, explicit ones take precedence (useful for buggy IDPs).
139
+
140
+ 3. **Full manual**: No ``issuer``, all required endpoints explicit.
141
+ No discovery attempted (for IDPs without discovery support).
142
+
143
+ Args:
144
+ name: Provider identifier.
145
+ config: Provider configuration.
146
+ token_storage: Token storage backend.
147
+ http_client: Optional custom HTTP client.
148
+
149
+ Raises:
150
+ ConfigurationError: If configuration is invalid.
151
+ """
152
+ # Track which endpoints were explicitly configured (before any modification)
153
+ endpoint_map = [
154
+ ("authorize_url", "authorization_endpoint"),
155
+ ("token_url", "token_endpoint"),
156
+ ("userinfo_url", "userinfo_endpoint"),
157
+ ("jwks_uri", "jwks_uri"),
158
+ ("end_session_endpoint", "end_session_endpoint"),
159
+ ("revoke_url", "revocation_endpoint"),
160
+ ]
161
+ self._explicit_endpoints: dict[str, str] = {
162
+ discovery_key: getattr(config, attr) for attr, discovery_key in endpoint_map if getattr(config, attr)
163
+ }
164
+
165
+ # Determine discovery mode
166
+ self._discovery_enabled = config.issuer is not None
167
+
168
+ # For auto-discovery mode, set temporary placeholders ONLY if no explicit endpoints
169
+ # These will be replaced by discovery
170
+ if self._discovery_enabled:
171
+ issuer = config.issuer
172
+ assert issuer is not None # Guaranteed by _discovery_enabled check
173
+ if not config.authorize_url:
174
+ config.authorize_url = f"{issuer.rstrip('/')}/authorize" # Placeholder
175
+ if not config.token_url:
176
+ config.token_url = f"{issuer.rstrip('/')}/token" # Placeholder
177
+
178
+ super().__init__(name, config, token_storage, http_client=http_client)
179
+
180
+ # Validate configuration
181
+ if not self._discovery_enabled:
182
+ # Full manual mode: require minimum endpoints
183
+ self._validate_manual_config()
184
+
185
+ # OIDC-specific state
186
+ self._discovery_doc: dict[str, Any] | None = None
187
+ self._discovery_fetched_at: datetime | None = None
188
+ self._discovered_issuer: str | None = None # Issuer from discovery (authoritative)
189
+ self._code_verifier: str | None = None
190
+ self._jwks: dict[str, Any] | None = None
191
+
192
+ # Ensure 'openid' scope is included
193
+ if "openid" not in config.scopes:
194
+ config.scopes = ["openid", *config.scopes]
195
+
196
+ def _validate_manual_config(self) -> None:
197
+ """Validate configuration for full manual mode (no discovery).
198
+
199
+ Note: Basic endpoint validation (authorize_url, token_url) is handled by
200
+ OAuth2Provider.__init__ which runs before this method. This method only
201
+ handles OIDC-specific warnings.
202
+ """
203
+ # Warn about missing but recommended endpoints for ID token validation
204
+ if not self.config.jwks_uri:
205
+ logger.warning(
206
+ "Provider '%s': jwks_uri not configured. ID token signature verification may fail.",
207
+ self.name,
208
+ )
209
+
210
+ @property
211
+ def flow(self) -> AuthFlow:
212
+ """Return the OAuth2/OIDC flow type."""
213
+ return AuthFlow.AUTHORIZATION_CODE_PKCE if self.config.pkce else AuthFlow.AUTHORIZATION_CODE
214
+
215
+ @property
216
+ def discovery_mode(self) -> str:
217
+ """Return the current discovery mode.
218
+
219
+ Returns:
220
+ One of: "auto", "hybrid", "manual"
221
+ """
222
+ if not self._discovery_enabled:
223
+ return "manual"
224
+ if self._explicit_endpoints:
225
+ return "hybrid"
226
+ return "auto"
227
+
228
+ # ─────────────────────────────────────────────────────────────────────────
229
+ # Discovery
230
+ # ─────────────────────────────────────────────────────────────────────────
231
+
232
+ def discover(self, *, force: bool = False) -> dict[str, Any]:
233
+ """Fetch and cache the OIDC discovery document.
234
+
235
+ In manual mode (no issuer), this returns an empty dict without
236
+ making any network calls. In auto/hybrid mode, it fetches the
237
+ discovery document and updates endpoints accordingly.
238
+
239
+ Args:
240
+ force: Force refresh even if cached.
241
+
242
+ Returns:
243
+ Discovery document as dict (empty in manual mode).
244
+
245
+ Raises:
246
+ DiscoveryError: If discovery fails (only in auto/hybrid mode).
247
+ """
248
+ # Manual mode: no discovery, return empty dict
249
+ if not self._discovery_enabled:
250
+ logger.debug(
251
+ "Provider '%s' in manual mode, skipping discovery",
252
+ self.name,
253
+ )
254
+ return {}
255
+
256
+ # Check cache
257
+ if not force and self._discovery_doc and self._discovery_fetched_at:
258
+ age = (datetime.now(timezone.utc) - self._discovery_fetched_at).total_seconds()
259
+ if age < self.config.discovery_ttl:
260
+ return self._discovery_doc
261
+
262
+ assert self.config.issuer is not None
263
+ discovery_url = f"{self.config.issuer.rstrip('/')}/.well-known/openid-configuration"
264
+
265
+ if logger.isEnabledFor(TRACE_LEVEL):
266
+ logger.log(TRACE_LEVEL, "[OIDC] Fetching discovery document from %s", discovery_url)
267
+
268
+ try:
269
+ response = self.http_client.get(discovery_url)
270
+ response.raise_for_status()
271
+ discovery_doc: dict[str, Any] = response.json()
272
+ self._discovery_doc = discovery_doc
273
+ self._discovery_fetched_at = datetime.now(timezone.utc)
274
+
275
+ if logger.isEnabledFor(TRACE_LEVEL):
276
+ endpoints_found = [k for k in discovery_doc if k.endswith("_endpoint") or k == "jwks_uri"]
277
+ logger.log(
278
+ TRACE_LEVEL,
279
+ "[OIDC] Discovery response: issuer=%s | endpoints=%s",
280
+ discovery_doc.get("issuer"),
281
+ endpoints_found,
282
+ )
283
+ except httpx.HTTPStatusError as e:
284
+ raise DiscoveryError(
285
+ self.config.issuer or "unknown",
286
+ f"HTTP {e.response.status_code}",
287
+ ) from e
288
+ except httpx.RequestError as e:
289
+ raise DiscoveryError(
290
+ self.config.issuer or "unknown",
291
+ str(e),
292
+ ) from e
293
+
294
+ # Store discovered issuer (authoritative for token validation)
295
+ discovered_issuer = discovery_doc.get("issuer")
296
+ if discovered_issuer:
297
+ self._discovered_issuer = discovered_issuer
298
+ # Warn if configured issuer differs from discovered (common with enterprise IDPs)
299
+ if self.config.issuer and discovered_issuer != self.config.issuer:
300
+ logger.debug(
301
+ "Provider '%s': discovered issuer differs from configured "
302
+ "(configured=%s, discovered=%s). Using discovered issuer for token validation.",
303
+ self.name,
304
+ self.config.issuer,
305
+ discovered_issuer,
306
+ )
307
+
308
+ # Update endpoints from discovery (respects explicit overrides)
309
+ self._update_endpoints_from_discovery()
310
+
311
+ mode = self.discovery_mode
312
+ logger.info(
313
+ "OIDC discovery completed for '%s' (mode: %s)",
314
+ self.config.issuer,
315
+ mode,
316
+ )
317
+ assert self._discovery_doc is not None
318
+ return self._discovery_doc
319
+
320
+ def _update_endpoints_from_discovery(self) -> None:
321
+ """Update config endpoints from discovery document.
322
+
323
+ In hybrid mode, explicit endpoints take precedence over discovered ones.
324
+ Only endpoints not explicitly configured are updated from discovery.
325
+ """
326
+ if not self._discovery_doc:
327
+ return
328
+
329
+ # Map discovery keys to config attributes
330
+ endpoint_mapping = {
331
+ "authorization_endpoint": "authorize_url",
332
+ "token_endpoint": "token_url",
333
+ "revocation_endpoint": "revoke_url",
334
+ "userinfo_endpoint": "userinfo_url",
335
+ "jwks_uri": "jwks_uri",
336
+ "end_session_endpoint": "end_session_endpoint",
337
+ }
338
+
339
+ for discovery_key, config_attr in endpoint_mapping.items():
340
+ # Skip if explicitly configured (hybrid mode: explicit wins)
341
+ if discovery_key in self._explicit_endpoints:
342
+ logger.debug(
343
+ "Provider '%s': keeping explicit %s (hybrid mode)",
344
+ self.name,
345
+ discovery_key,
346
+ )
347
+ continue
348
+
349
+ # Update from discovery if available
350
+ if discovery_key in self._discovery_doc:
351
+ setattr(self.config, config_attr, self._discovery_doc[discovery_key])
352
+ logger.debug(
353
+ "Provider '%s': set %s from discovery",
354
+ self.name,
355
+ discovery_key,
356
+ )
357
+
358
+ # ─────────────────────────────────────────────────────────────────────────
359
+ # PKCE
360
+ # ─────────────────────────────────────────────────────────────────────────
361
+
362
+ def _generate_pkce(self) -> tuple[str, str]:
363
+ """Generate PKCE code_verifier and code_challenge.
364
+
365
+ Returns:
366
+ Tuple of (code_verifier, code_challenge).
367
+ """
368
+ # Generate 32 bytes of random data for code_verifier
369
+ # Base64url encode -> 43 characters
370
+ code_verifier = secrets.token_urlsafe(32)
371
+ self._code_verifier = code_verifier
372
+
373
+ # Create code_challenge = base64url(sha256(code_verifier))
374
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
375
+ code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
376
+
377
+ if logger.isEnabledFor(TRACE_LEVEL):
378
+ logger.log(
379
+ TRACE_LEVEL,
380
+ "[PKCE] Generated code_verifier (len=%d) | challenge_method=S256",
381
+ len(code_verifier),
382
+ )
383
+
384
+ return code_verifier, code_challenge
385
+
386
+ # ─────────────────────────────────────────────────────────────────────────
387
+ # Override OAuth2 methods for OIDC
388
+ # ─────────────────────────────────────────────────────────────────────────
389
+
390
+ def get_authorization_url(self, state: str | None = None) -> tuple[str, str]:
391
+ """Generate the authorization URL with PKCE if enabled.
392
+
393
+ Args:
394
+ state: Optional state parameter.
395
+
396
+ Returns:
397
+ Tuple of (authorization_url, state).
398
+ """
399
+ # Ensure discovery is done first
400
+ self.discover()
401
+
402
+ if state is None:
403
+ state = secrets.token_urlsafe(32)
404
+
405
+ self._pending_state = state
406
+
407
+ params = {
408
+ "response_type": "code",
409
+ "client_id": self.config.client_id,
410
+ "redirect_uri": self.config.redirect_uri,
411
+ "state": state,
412
+ "scope": " ".join(self.config.scopes),
413
+ }
414
+
415
+ # Add PKCE if enabled
416
+ if self.config.pkce:
417
+ _, code_challenge = self._generate_pkce()
418
+ params["code_challenge"] = code_challenge
419
+ params["code_challenge_method"] = "S256"
420
+
421
+ # Add nonce for OIDC (prevents replay attacks)
422
+ nonce = secrets.token_urlsafe(16)
423
+ params["nonce"] = nonce
424
+
425
+ # Add any extra parameters
426
+ params.update(self.config.extra.get("authorize_params", {}))
427
+
428
+ from urllib.parse import urlencode
429
+
430
+ url = f"{self.config.authorize_url}?{urlencode(params)}"
431
+ logger.debug("Generated OIDC authorization URL for provider '%s' (PKCE=%s)", self.name, self.config.pkce)
432
+ return url, state
433
+
434
+ def exchange_code(
435
+ self,
436
+ code: str,
437
+ state: str,
438
+ *,
439
+ code_verifier: str | None = None,
440
+ ) -> Token:
441
+ """Exchange authorization code for tokens, with PKCE support.
442
+
443
+ Args:
444
+ code: Authorization code from callback.
445
+ state: State parameter for validation.
446
+ code_verifier: PKCE code verifier (auto-used from internal state if not provided).
447
+
448
+ Returns:
449
+ Token with access_token, id_token, etc.
450
+
451
+ Raises:
452
+ TokenExchangeError: If exchange fails.
453
+ """
454
+ # Use internally stored code_verifier if not provided
455
+ if code_verifier is None and self.config.pkce:
456
+ code_verifier = self._code_verifier
457
+
458
+ if self.config.pkce and not code_verifier:
459
+ msg = "PKCE is enabled but no code_verifier available"
460
+ raise TokenExchangeError(msg, error_code="pkce_missing")
461
+
462
+ # Call parent implementation with code_verifier
463
+ token = super().exchange_code(code, state, code_verifier=code_verifier)
464
+
465
+ # Clear code_verifier after use
466
+ self._code_verifier = None
467
+
468
+ # Validate ID token if present
469
+ if token.id_token:
470
+ try:
471
+ self._validate_id_token(token.id_token)
472
+ except TokenValidationError as e:
473
+ logger.warning("ID token validation failed: %s", e)
474
+ # Don't fail the exchange, just log warning
475
+ # Application can decide whether to reject
476
+
477
+ return token
478
+
479
+ # ─────────────────────────────────────────────────────────────────────────
480
+ # ID Token validation
481
+ # ─────────────────────────────────────────────────────────────────────────
482
+
483
+ def _validate_id_token(self, id_token: str) -> dict[str, Any]:
484
+ """Validate and decode an ID token.
485
+
486
+ Args:
487
+ id_token: JWT ID token.
488
+
489
+ Returns:
490
+ Decoded claims.
491
+
492
+ Raises:
493
+ TokenValidationError: If validation fails.
494
+ """
495
+ # Use discovered issuer if available (authoritative), fallback to configured
496
+ # This handles cases where the IDP returns a different issuer in discovery
497
+ # (e.g., with port or path suffix like :443/oauth2)
498
+ expected_issuer = self._discovered_issuer or self.config.issuer
499
+
500
+ if logger.isEnabledFor(TRACE_LEVEL):
501
+ logger.log(TRACE_LEVEL, "[ID_TOKEN] Validating token (expected issuer=%s)", expected_issuer)
502
+
503
+ try:
504
+ # Try using authlib if available
505
+ from authlib.jose import jwt
506
+ from authlib.jose.errors import JoseError
507
+
508
+ # Fetch JWKS for signature verification
509
+ jwks = self._get_jwks()
510
+
511
+ claims = jwt.decode( # type: ignore[call-overload]
512
+ id_token,
513
+ jwks, # pyright: ignore[reportArgumentType] - authlib accepts dict JWKS
514
+ claims_options={
515
+ "iss": {"essential": True, "value": expected_issuer},
516
+ "aud": {"essential": True, "value": self.config.client_id},
517
+ "exp": {"essential": True},
518
+ },
519
+ )
520
+ claims.validate()
521
+
522
+ if logger.isEnabledFor(TRACE_LEVEL):
523
+ logger.log(
524
+ TRACE_LEVEL,
525
+ "[ID_TOKEN] Validated: iss=%s | aud=%s | sub=%s",
526
+ claims.get("iss"),
527
+ claims.get("aud"),
528
+ claims.get("sub"),
529
+ )
530
+
531
+ return dict(claims)
532
+ except ImportError:
533
+ # Fallback: decode without verification (not recommended for production)
534
+ logger.warning("authlib not available, skipping ID token signature verification")
535
+ return self._decode_jwt_unverified(id_token)
536
+ except JoseError as e:
537
+ raise TokenValidationError(str(e)) from e
538
+
539
+ def _decode_jwt_unverified(self, token: str) -> dict[str, Any]:
540
+ """Decode JWT without signature verification (fallback)."""
541
+ import json
542
+
543
+ try:
544
+ parts = token.split(".")
545
+ if len(parts) != 3:
546
+ raise TokenValidationError("Invalid JWT format")
547
+
548
+ # Decode payload (second part)
549
+ payload = parts[1]
550
+ # Add padding if needed
551
+ padding = 4 - len(payload) % 4
552
+ if padding != 4:
553
+ payload += "=" * padding
554
+
555
+ decoded = base64.urlsafe_b64decode(payload)
556
+ result: dict[str, Any] = json.loads(decoded)
557
+ return result
558
+ except Exception as e:
559
+ raise TokenValidationError(f"Failed to decode JWT: {e}") from e
560
+
561
+ def _get_jwks(self) -> dict[str, Any]:
562
+ """Fetch JSON Web Key Set for ID token verification.
563
+
564
+ Uses explicit jwks_uri if configured, otherwise gets it from discovery.
565
+ """
566
+ if self._jwks:
567
+ return self._jwks
568
+
569
+ # Try explicit config first (manual/hybrid mode)
570
+ jwks_uri = self.config.jwks_uri
571
+
572
+ # Fall back to discovery
573
+ if not jwks_uri and self._discovery_enabled:
574
+ discovery = self.discover()
575
+ jwks_uri = discovery.get("jwks_uri")
576
+
577
+ if not jwks_uri:
578
+ raise TokenValidationError(
579
+ "No jwks_uri configured or found in discovery. "
580
+ "Configure 'jwks_uri' explicitly or ensure discovery document contains it."
581
+ )
582
+
583
+ if logger.isEnabledFor(TRACE_LEVEL):
584
+ logger.log(TRACE_LEVEL, "[JWKS] Fetching keys from %s", jwks_uri)
585
+
586
+ try:
587
+ response = self.http_client.get(jwks_uri)
588
+ response.raise_for_status()
589
+ self._jwks = response.json()
590
+ assert self._jwks is not None
591
+
592
+ if logger.isEnabledFor(TRACE_LEVEL):
593
+ keys = self._jwks.get("keys", [])
594
+ key_ids = [k.get("kid", "no-kid") for k in keys]
595
+ logger.log(TRACE_LEVEL, "[JWKS] Loaded %d keys: %s", len(keys), key_ids)
596
+
597
+ return self._jwks
598
+ except httpx.HTTPStatusError as e:
599
+ raise TokenValidationError(f"Failed to fetch JWKS from {jwks_uri}: HTTP {e.response.status_code}") from e
600
+ except httpx.RequestError as e:
601
+ raise TokenValidationError(f"Failed to fetch JWKS from {jwks_uri}: {e}") from e
602
+
603
+ # ─────────────────────────────────────────────────────────────────────────
604
+ # Token refresh (with discovery)
605
+ # ─────────────────────────────────────────────────────────────────────────
606
+
607
+ def refresh(self, token: Token | None = None) -> Token:
608
+ """Refresh an access token, ensuring OIDC discovery is done first.
609
+
610
+ For OIDC providers, we must perform discovery before refreshing
611
+ to ensure we have the correct token_endpoint URL. This is necessary
612
+ because the endpoint URLs set during __init__ are temporary fallbacks
613
+ that may not match the actual IDP endpoints.
614
+
615
+ Args:
616
+ token: Token to refresh. Uses stored token if not provided.
617
+
618
+ Returns:
619
+ New token with refreshed access_token.
620
+
621
+ Raises:
622
+ TokenRefreshError: If refresh fails.
623
+ """
624
+ # Ensure discovery is done to get correct token_endpoint
625
+ self.discover()
626
+ return super().refresh(token)
627
+
628
+ # ─────────────────────────────────────────────────────────────────────────
629
+ # UserInfo endpoint
630
+ # ─────────────────────────────────────────────────────────────────────────
631
+
632
+ def get_userinfo(self, token: Token | None = None) -> dict[str, Any]:
633
+ """Fetch user information from the UserInfo endpoint.
634
+
635
+ Uses explicit userinfo_url if configured, otherwise gets it from discovery.
636
+
637
+ Args:
638
+ token: Token to use. Uses stored token if not provided.
639
+
640
+ Returns:
641
+ User claims from the UserInfo endpoint.
642
+
643
+ Raises:
644
+ AuthError: If request fails or endpoint not configured.
645
+ """
646
+ if token is None:
647
+ token = self.get_token()
648
+
649
+ if token is None:
650
+ msg = "No token available"
651
+ raise TokenValidationError(msg)
652
+
653
+ # Try explicit config first (manual/hybrid mode)
654
+ userinfo_endpoint = self.config.userinfo_url
655
+
656
+ # Fall back to discovery
657
+ if not userinfo_endpoint and self._discovery_enabled:
658
+ discovery = self.discover()
659
+ userinfo_endpoint = discovery.get("userinfo_endpoint")
660
+
661
+ if not userinfo_endpoint:
662
+ msg = (
663
+ "No userinfo_endpoint configured or found in discovery. "
664
+ "Configure 'userinfo_url' explicitly or ensure discovery document contains it."
665
+ )
666
+ raise ConfigurationError(msg)
667
+
668
+ headers = {"Authorization": f"Bearer {token.access_token}"}
669
+ response = self.http_client.get(
670
+ userinfo_endpoint,
671
+ headers=headers,
672
+ )
673
+ response.raise_for_status()
674
+ result: dict[str, Any] = response.json()
675
+ return result
676
+
677
+ # ─────────────────────────────────────────────────────────────────────────
678
+ # Preflight validation (extended for OIDC)
679
+ # ─────────────────────────────────────────────────────────────────────────
680
+
681
+ def preflight(self) -> PreflightReport:
682
+ """Run preflight validation with OIDC-specific checks."""
683
+ report = PreflightReport(provider_name=self.name)
684
+
685
+ # Check 1: Configuration
686
+ report.results.append(self._check_config())
687
+
688
+ # Check 2: Discovery endpoint
689
+ report.results.append(self._check_discovery())
690
+
691
+ # Check 3: JWKS endpoint
692
+ report.results.append(self._check_jwks())
693
+
694
+ # Check 4: Required scopes supported
695
+ report.results.append(self._check_scopes())
696
+
697
+ # Check 5: Authorization endpoint
698
+ report.results.append(self._check_endpoint("authorize", self.config.authorize_url))
699
+
700
+ # Check 6: Token endpoint
701
+ report.results.append(self._check_endpoint("token", self.config.token_url))
702
+
703
+ return report
704
+
705
+ def _check_discovery(self) -> PreflightResult:
706
+ """Check OIDC discovery endpoint."""
707
+ start = time.time()
708
+ try:
709
+ doc = self.discover(force=True)
710
+ duration = int((time.time() - start) * 1000)
711
+
712
+ required_fields = ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"]
713
+ missing = [f for f in required_fields if f not in doc]
714
+
715
+ if missing:
716
+ return PreflightResult(
717
+ step="discovery",
718
+ status=PreflightStatus.WARNING,
719
+ message=f"Discovery missing fields: {', '.join(missing)}",
720
+ details={"missing": missing, "found": list(doc)},
721
+ duration_ms=duration,
722
+ )
723
+
724
+ return PreflightResult(
725
+ step="discovery",
726
+ status=PreflightStatus.SUCCESS,
727
+ message="Discovery document valid",
728
+ details={
729
+ "issuer": doc.get("issuer"),
730
+ "endpoints": len([k for k in doc if k.endswith("_endpoint")]),
731
+ },
732
+ duration_ms=duration,
733
+ )
734
+ except DiscoveryError as e:
735
+ duration = int((time.time() - start) * 1000)
736
+ return PreflightResult(
737
+ step="discovery",
738
+ status=PreflightStatus.FAILURE,
739
+ message=f"Discovery failed: {e.reason}",
740
+ details={"issuer": self.config.issuer},
741
+ duration_ms=duration,
742
+ )
743
+
744
+ def _check_jwks(self) -> PreflightResult:
745
+ """Check JWKS endpoint."""
746
+ start = time.time()
747
+ try:
748
+ jwks = self._get_jwks()
749
+ duration = int((time.time() - start) * 1000)
750
+
751
+ keys = jwks.get("keys", [])
752
+ if not keys:
753
+ return PreflightResult(
754
+ step="jwks",
755
+ status=PreflightStatus.WARNING,
756
+ message="JWKS contains no keys",
757
+ duration_ms=duration,
758
+ )
759
+
760
+ return PreflightResult(
761
+ step="jwks",
762
+ status=PreflightStatus.SUCCESS,
763
+ message=f"JWKS valid ({len(keys)} keys)",
764
+ details={"key_count": len(keys)},
765
+ duration_ms=duration,
766
+ )
767
+ except Exception as e: # pylint: disable=broad-exception-caught
768
+ # Preflight returns result for any error
769
+ duration = int((time.time() - start) * 1000)
770
+ return PreflightResult(
771
+ step="jwks",
772
+ status=PreflightStatus.FAILURE,
773
+ message=f"JWKS fetch failed: {e}",
774
+ duration_ms=duration,
775
+ )
776
+
777
+ def _check_scopes(self) -> PreflightResult:
778
+ """Check if required scopes are supported."""
779
+ start = time.time()
780
+ try:
781
+ doc = self.discover()
782
+ supported = doc.get("scopes_supported", [])
783
+ duration = int((time.time() - start) * 1000)
784
+
785
+ if not supported:
786
+ return PreflightResult(
787
+ step="scopes",
788
+ status=PreflightStatus.WARNING,
789
+ message="Server does not advertise supported scopes",
790
+ duration_ms=duration,
791
+ )
792
+
793
+ unsupported = [s for s in self.config.scopes if s not in supported]
794
+ if unsupported:
795
+ return PreflightResult(
796
+ step="scopes",
797
+ status=PreflightStatus.WARNING,
798
+ message=f"Requested scopes may not be supported: {', '.join(unsupported)}",
799
+ details={"unsupported": unsupported, "supported": supported},
800
+ duration_ms=duration,
801
+ )
802
+
803
+ return PreflightResult(
804
+ step="scopes",
805
+ status=PreflightStatus.SUCCESS,
806
+ message="All requested scopes are supported",
807
+ details={"requested": self.config.scopes},
808
+ duration_ms=duration,
809
+ )
810
+ except Exception as e: # pylint: disable=broad-exception-caught
811
+ # Preflight returns result for any error
812
+ duration = int((time.time() - start) * 1000)
813
+ return PreflightResult(
814
+ step="scopes",
815
+ status=PreflightStatus.FAILURE,
816
+ message=f"Scope check failed: {e}",
817
+ duration_ms=duration,
818
+ )
819
+
820
+
821
+ __all__ = ["OIDCProvider"]