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,645 @@
1
+ """OAuth2 Authorization Code provider implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+ from http import HTTPStatus
8
+ from typing import TYPE_CHECKING, Any
9
+ from urllib.parse import urlencode
10
+
11
+ import httpx
12
+
13
+ from kstlib.auth.errors import (
14
+ AuthError,
15
+ ConfigurationError,
16
+ TokenExchangeError,
17
+ TokenRefreshError,
18
+ )
19
+ from kstlib.auth.models import (
20
+ AuthFlow,
21
+ PreflightReport,
22
+ PreflightResult,
23
+ PreflightStatus,
24
+ Token,
25
+ )
26
+ from kstlib.auth.providers.base import (
27
+ AbstractAuthProvider,
28
+ AuthProviderConfig,
29
+ load_provider_from_config,
30
+ )
31
+ from kstlib.logging import TRACE_LEVEL, get_logger
32
+ from kstlib.utils.http_trace import HTTPTraceLogger
33
+
34
+ if TYPE_CHECKING:
35
+ from kstlib.auth.token import AbstractTokenStorage
36
+
37
+ logger = get_logger(__name__)
38
+
39
+ # Default trace settings (can be overridden by config)
40
+ # TRACE mode = debug mode, show full body by default
41
+ _TRACE_MAX_BODY_DEFAULT = 10000
42
+ _TRACE_MAX_BODY_HARD_LIMIT = 10000 # Defense in depth: never log more than 10KB
43
+ _TRACE_PRETTY_DEFAULT = True
44
+
45
+ # Default timeout for HTTP requests
46
+ DEFAULT_TIMEOUT = 30.0
47
+
48
+
49
+ class OAuth2Provider(AbstractAuthProvider):
50
+ """OAuth2 Authorization Code flow provider.
51
+
52
+ Implements the standard OAuth2 Authorization Code flow for confidential
53
+ clients. For public clients or enhanced security, use OIDCProvider with PKCE.
54
+
55
+ Example:
56
+ >>> from kstlib.auth.providers import OAuth2Provider, AuthProviderConfig # doctest: +SKIP
57
+ >>> from kstlib.auth.token import MemoryTokenStorage # doctest: +SKIP
58
+ >>>
59
+ >>> config = AuthProviderConfig( # doctest: +SKIP
60
+ ... client_id="my-app",
61
+ ... client_secret="secret",
62
+ ... authorize_url="https://auth.example.com/authorize",
63
+ ... token_url="https://auth.example.com/token",
64
+ ... scopes=["read", "write"],
65
+ ... )
66
+ >>> provider = OAuth2Provider("example", config, MemoryTokenStorage()) # doctest: +SKIP
67
+ >>> url, state = provider.get_authorization_url() # doctest: +SKIP
68
+ >>> # User visits URL, authorizes, redirected back with code
69
+ >>> token = provider.exchange_code(code="...", state=state) # doctest: +SKIP
70
+
71
+ Config-driven usage:
72
+ >>> # Configure in kstlib.conf.yml:
73
+ >>> # auth:
74
+ >>> # providers:
75
+ >>> # github:
76
+ >>> # type: oauth2
77
+ >>> # authorization_endpoint: https://github.com/login/oauth/authorize
78
+ >>> # token_endpoint: https://github.com/login/oauth/access_token
79
+ >>> # client_id: my-app
80
+ >>> # client_secret: sops://secrets.yaml#github.secret
81
+ >>> provider = OAuth2Provider.from_config("github") # doctest: +SKIP
82
+ """
83
+
84
+ @classmethod
85
+ def from_config(
86
+ cls,
87
+ provider_name: str,
88
+ *,
89
+ config: dict[str, Any] | None = None,
90
+ http_client: httpx.Client | None = None,
91
+ **overrides: Any,
92
+ ) -> OAuth2Provider:
93
+ """Create an OAuth2Provider from configuration.
94
+
95
+ Loads provider settings from kstlib.conf.yml (auth.providers section)
96
+ and creates a fully configured provider instance.
97
+
98
+ Args:
99
+ provider_name: Name of the provider in config.
100
+ config: Optional explicit config dict (overrides global config).
101
+ http_client: Optional custom HTTP client.
102
+ **overrides: Direct parameter overrides (highest priority).
103
+
104
+ Returns:
105
+ Configured OAuth2Provider instance.
106
+
107
+ Raises:
108
+ ConfigurationError: If provider not found or required fields missing.
109
+
110
+ Example:
111
+ >>> provider = OAuth2Provider.from_config("github") # doctest: +SKIP
112
+ """
113
+ auth_config, token_storage = load_provider_from_config(
114
+ provider_name,
115
+ allowed_types=("oauth2", "oauth"),
116
+ type_label="oauth2",
117
+ config=config,
118
+ **overrides,
119
+ )
120
+
121
+ return cls(
122
+ name=provider_name,
123
+ config=auth_config,
124
+ token_storage=token_storage,
125
+ http_client=http_client,
126
+ )
127
+
128
+ def __init__(
129
+ self,
130
+ name: str,
131
+ config: AuthProviderConfig,
132
+ token_storage: AbstractTokenStorage,
133
+ *,
134
+ http_client: httpx.Client | None = None,
135
+ ) -> None:
136
+ """Initialize OAuth2 provider.
137
+
138
+ Args:
139
+ name: Provider identifier.
140
+ config: Provider configuration.
141
+ token_storage: Token storage backend.
142
+ http_client: Optional custom HTTP client.
143
+ """
144
+ super().__init__(name, config, token_storage)
145
+ self._http_client = http_client
146
+ self._pending_state: str | None = None
147
+ self._tracer: HTTPTraceLogger | None = None
148
+
149
+ # Validate required OAuth2 config
150
+ if not config.authorize_url or not config.token_url:
151
+ msg = "OAuth2Provider requires 'authorize_url' and 'token_url' in config"
152
+ raise ConfigurationError(msg)
153
+
154
+ @property
155
+ def flow(self) -> AuthFlow:
156
+ """Return the OAuth2 flow type."""
157
+ return AuthFlow.AUTHORIZATION_CODE
158
+
159
+ @property
160
+ def tracer(self) -> HTTPTraceLogger:
161
+ """Get or create HTTP trace logger with config-driven settings."""
162
+ if self._tracer is None:
163
+ pretty, max_body = self._get_trace_config()
164
+ self._tracer = HTTPTraceLogger(
165
+ logger,
166
+ trace_level=TRACE_LEVEL,
167
+ pretty_print=pretty,
168
+ max_body_length=max_body,
169
+ )
170
+ return self._tracer
171
+
172
+ @property
173
+ def http_client(self) -> httpx.Client:
174
+ """Get or create HTTP client with TRACE logging hooks.
175
+
176
+ The client automatically includes any custom headers configured in
177
+ ``config.headers``. These headers are sent with all IDP requests,
178
+ useful for environments requiring specific headers (e.g., Host header
179
+ for load balancer validation).
180
+
181
+ SSL verification is controlled by ``config.ssl_verify`` and
182
+ ``config.ssl_ca_bundle``. See :class:`AuthProviderConfig` for details.
183
+ """
184
+ if self._http_client is None:
185
+ self._http_client = httpx.Client(
186
+ timeout=DEFAULT_TIMEOUT,
187
+ headers=self.config.headers or {},
188
+ verify=self._build_ssl_context(),
189
+ event_hooks={
190
+ "request": [self.tracer.on_request],
191
+ "response": [self.tracer.on_response],
192
+ },
193
+ )
194
+ return self._http_client
195
+
196
+ def _build_ssl_context(self) -> bool | str:
197
+ """Build SSL verification context from config.
198
+
199
+ Returns:
200
+ - str: Path to CA bundle (if ssl_ca_bundle configured)
201
+ - True: Default SSL verification (if ssl_verify=True, no custom CA)
202
+ - False: Disable SSL verification (if ssl_verify=False)
203
+
204
+ Note:
205
+ ssl_ca_bundle takes precedence over ssl_verify=False.
206
+ This is intentional: if you specify a CA bundle, you want verification.
207
+ """
208
+ if self.config.ssl_ca_bundle:
209
+ return self.config.ssl_ca_bundle
210
+ return self.config.ssl_verify
211
+
212
+ # ─────────────────────────────────────────────────────────────────────────
213
+ # Authorization flow
214
+ # ─────────────────────────────────────────────────────────────────────────
215
+
216
+ def get_authorization_url(self, state: str | None = None) -> tuple[str, str]:
217
+ """Generate the authorization URL.
218
+
219
+ Args:
220
+ state: Optional state parameter. Generated if not provided.
221
+
222
+ Returns:
223
+ Tuple of (authorization_url, state).
224
+ """
225
+ if state is None:
226
+ state = secrets.token_urlsafe(32)
227
+
228
+ self._pending_state = state
229
+
230
+ params = {
231
+ "response_type": "code",
232
+ "client_id": self.config.client_id,
233
+ "redirect_uri": self.config.redirect_uri,
234
+ "state": state,
235
+ }
236
+
237
+ if self.config.scopes:
238
+ params["scope"] = " ".join(self.config.scopes)
239
+
240
+ # Add any extra parameters from config
241
+ params.update(self.config.extra.get("authorize_params", {}))
242
+
243
+ url = f"{self.config.authorize_url}?{urlencode(params)}"
244
+ logger.debug("Generated authorization URL for provider '%s'", self.name)
245
+ return url, state
246
+
247
+ def exchange_code(
248
+ self,
249
+ code: str,
250
+ state: str,
251
+ *,
252
+ code_verifier: str | None = None,
253
+ ) -> Token:
254
+ """Exchange authorization code for tokens.
255
+
256
+ Args:
257
+ code: Authorization code from callback.
258
+ state: State parameter for validation.
259
+ code_verifier: PKCE code verifier (ignored for basic OAuth2).
260
+
261
+ Returns:
262
+ Token with access_token and optionally refresh_token.
263
+
264
+ Raises:
265
+ TokenExchangeError: If exchange fails.
266
+ """
267
+ # Validate state
268
+ if self._pending_state and state != self._pending_state:
269
+ msg = "State mismatch - possible CSRF attack"
270
+ raise TokenExchangeError(msg, error_code="state_mismatch")
271
+
272
+ data = {
273
+ "grant_type": "authorization_code",
274
+ "code": code,
275
+ "redirect_uri": self.config.redirect_uri,
276
+ "client_id": self.config.client_id,
277
+ }
278
+
279
+ # Add client_secret for confidential clients
280
+ if self.config.client_secret:
281
+ data["client_secret"] = self.config.client_secret
282
+
283
+ # Add PKCE code_verifier if provided (for subclasses)
284
+ if code_verifier:
285
+ data["code_verifier"] = code_verifier
286
+
287
+ assert self.config.token_url is not None # Validated in __init__
288
+ headers = {"Accept": "application/json"}
289
+ try:
290
+ response = self.http_client.post(
291
+ self.config.token_url,
292
+ data=data,
293
+ headers=headers,
294
+ )
295
+ response.raise_for_status()
296
+ token_data = response.json()
297
+ except httpx.HTTPStatusError as e:
298
+ error_data = self._parse_error_response(e.response)
299
+ raise TokenExchangeError(
300
+ error_data.get("error_description", str(e)),
301
+ error_code=error_data.get("error"),
302
+ ) from e
303
+ except httpx.RequestError as e:
304
+ raise TokenExchangeError(f"Network error: {e}") from e
305
+
306
+ token = Token.from_response(token_data)
307
+ self.save_token(token)
308
+ self._pending_state = None
309
+
310
+ logger.info("Token exchange successful for provider '%s'", self.name)
311
+ return token
312
+
313
+ def refresh(self, token: Token | None = None) -> Token:
314
+ """Refresh an expired token.
315
+
316
+ Args:
317
+ token: Token to refresh. Uses stored token if not provided.
318
+
319
+ Returns:
320
+ New Token.
321
+
322
+ Raises:
323
+ TokenRefreshError: If refresh fails.
324
+ """
325
+ if token is None:
326
+ token = self.get_token(auto_refresh=False)
327
+
328
+ if token is None:
329
+ raise TokenRefreshError("No token to refresh")
330
+
331
+ if not token.refresh_token:
332
+ raise TokenRefreshError("Token has no refresh_token", retryable=False)
333
+
334
+ data = {
335
+ "grant_type": "refresh_token",
336
+ "refresh_token": token.refresh_token,
337
+ "client_id": self.config.client_id,
338
+ }
339
+
340
+ if self.config.client_secret:
341
+ data["client_secret"] = self.config.client_secret
342
+
343
+ assert self.config.token_url is not None # Validated in __init__
344
+ headers = {"Accept": "application/json"}
345
+ try:
346
+ response = self.http_client.post(
347
+ self.config.token_url,
348
+ data=data,
349
+ headers=headers,
350
+ )
351
+ response.raise_for_status()
352
+ token_data = response.json()
353
+ except httpx.HTTPStatusError as e:
354
+ status = e.response.status_code
355
+ # Handle 404 specifically - likely wrong token_endpoint URL
356
+ if status == HTTPStatus.NOT_FOUND:
357
+ raise TokenRefreshError(
358
+ f"Token endpoint not found ({self.config.token_url}). "
359
+ "Please re-authenticate with 'kstlib auth login'.",
360
+ retryable=False,
361
+ ) from e
362
+ # Handle 401/400 - token likely expired or revoked
363
+ if status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.BAD_REQUEST):
364
+ error_data = self._parse_error_response(e.response)
365
+ error_desc = error_data.get("error_description", "")
366
+ raise TokenRefreshError(
367
+ f"Token refresh rejected: {error_desc or 'invalid or expired refresh token'}. "
368
+ "Please re-authenticate with 'kstlib auth login'.",
369
+ retryable=False,
370
+ ) from e
371
+ # Other errors
372
+ error_data = self._parse_error_response(e.response)
373
+ retryable = status >= HTTPStatus.INTERNAL_SERVER_ERROR
374
+ raise TokenRefreshError(
375
+ error_data.get("error_description", str(e)),
376
+ retryable=retryable,
377
+ ) from e
378
+ except httpx.RequestError as e:
379
+ raise TokenRefreshError(f"Network error: {e}", retryable=True) from e
380
+
381
+ # Preserve refresh_token if not returned in response
382
+ if "refresh_token" not in token_data and token.refresh_token:
383
+ token_data["refresh_token"] = token.refresh_token
384
+
385
+ new_token = Token.from_response(token_data)
386
+ self.save_token(new_token)
387
+
388
+ logger.info("Token refresh successful for provider '%s'", self.name)
389
+ return new_token
390
+
391
+ def revoke(self, token: Token | None = None) -> bool:
392
+ """Revoke a token.
393
+
394
+ Args:
395
+ token: Token to revoke. Uses stored token if not provided.
396
+
397
+ Returns:
398
+ True if revoked, False if revocation not supported.
399
+ """
400
+ if not self.config.revoke_url:
401
+ logger.debug("Revocation not configured for provider '%s'", self.name)
402
+ return False
403
+
404
+ if token is None:
405
+ token = self.get_token(auto_refresh=False)
406
+
407
+ if token is None:
408
+ return False
409
+
410
+ # Try revoking access_token first, then refresh_token
411
+ tokens_to_revoke = [
412
+ ("access_token", token.access_token),
413
+ ]
414
+ if token.refresh_token:
415
+ tokens_to_revoke.append(("refresh_token", token.refresh_token))
416
+
417
+ success = False
418
+ for token_type_hint, token_value in tokens_to_revoke:
419
+ try:
420
+ data: dict[str, Any] = {
421
+ "token": token_value,
422
+ "token_type_hint": token_type_hint,
423
+ "client_id": self.config.client_id,
424
+ }
425
+ if self.config.client_secret:
426
+ data["client_secret"] = self.config.client_secret
427
+
428
+ response = self.http_client.post(
429
+ self.config.revoke_url,
430
+ data=data,
431
+ )
432
+ # RFC 7009: 200 OK even if token was already invalid
433
+ if response.status_code == HTTPStatus.OK:
434
+ success = True
435
+ except httpx.RequestError as e:
436
+ logger.warning("Failed to revoke %s: %s", token_type_hint, e)
437
+
438
+ if success:
439
+ self.clear_token()
440
+ logger.info("Token revoked for provider '%s'", self.name)
441
+
442
+ return success
443
+
444
+ # ─────────────────────────────────────────────────────────────────────────
445
+ # UserInfo endpoint
446
+ # ─────────────────────────────────────────────────────────────────────────
447
+
448
+ def get_userinfo(self, token: Token | None = None) -> dict[str, Any]:
449
+ """Fetch user information from the UserInfo endpoint.
450
+
451
+ Requires `userinfo_url` to be configured in the provider config.
452
+
453
+ Args:
454
+ token: Token to use. Uses stored token if not provided.
455
+
456
+ Returns:
457
+ User claims from the UserInfo endpoint.
458
+
459
+ Raises:
460
+ ConfigurationError: If userinfo_url is not configured.
461
+ AuthError: If request fails.
462
+
463
+ Example:
464
+ >>> provider = OAuth2Provider.from_config("github") # doctest: +SKIP
465
+ >>> userinfo = provider.get_userinfo() # doctest: +SKIP
466
+ >>> print(userinfo["login"]) # doctest: +SKIP
467
+ """
468
+ if not self.config.userinfo_url:
469
+ msg = (
470
+ f"Provider '{self.name}' does not have 'userinfo_url' configured. "
471
+ "For OIDC providers, userinfo is auto-discovered. "
472
+ "For OAuth2, you must configure 'userinfo_url' explicitly."
473
+ )
474
+ raise ConfigurationError(msg)
475
+
476
+ if token is None:
477
+ token = self.get_token()
478
+
479
+ if token is None:
480
+ msg = "No token available"
481
+ raise AuthError(msg)
482
+
483
+ try:
484
+ headers = {"Authorization": f"Bearer {token.access_token}"}
485
+ response = self.http_client.get(
486
+ self.config.userinfo_url,
487
+ headers=headers,
488
+ )
489
+ response.raise_for_status()
490
+ result: dict[str, Any] = response.json()
491
+ return result
492
+ except httpx.HTTPStatusError as e:
493
+ msg = f"UserInfo request failed: HTTP {e.response.status_code}"
494
+ raise AuthError(msg) from e
495
+ except httpx.RequestError as e:
496
+ msg = f"UserInfo request failed: {e}"
497
+ raise AuthError(msg) from e
498
+
499
+ # ─────────────────────────────────────────────────────────────────────────
500
+ # Preflight validation
501
+ # ─────────────────────────────────────────────────────────────────────────
502
+
503
+ def preflight(self) -> PreflightReport:
504
+ """Run preflight validation checks.
505
+
506
+ Returns:
507
+ PreflightReport with validation results.
508
+ """
509
+ report = PreflightReport(provider_name=self.name)
510
+
511
+ # Check 1: Configuration
512
+ report.results.append(self._check_config())
513
+
514
+ # Check 2: Authorization endpoint reachable
515
+ report.results.append(self._check_endpoint("authorize", self.config.authorize_url))
516
+
517
+ # Check 3: Token endpoint reachable
518
+ report.results.append(self._check_endpoint("token", self.config.token_url))
519
+
520
+ # Check 4: Revocation endpoint (optional)
521
+ if self.config.revoke_url:
522
+ report.results.append(self._check_endpoint("revoke", self.config.revoke_url))
523
+
524
+ # Check 5: UserInfo endpoint (optional)
525
+ if self.config.userinfo_url:
526
+ report.results.append(self._check_endpoint("userinfo", self.config.userinfo_url))
527
+
528
+ return report
529
+
530
+ def _check_config(self) -> PreflightResult:
531
+ """Validate provider configuration."""
532
+ start = time.time()
533
+ issues: list[str] = []
534
+
535
+ if not self.config.client_id:
536
+ issues.append("client_id is required")
537
+ if not self.config.authorize_url:
538
+ issues.append("authorize_url is required")
539
+ if not self.config.token_url:
540
+ issues.append("token_url is required")
541
+ if not self.config.redirect_uri:
542
+ issues.append("redirect_uri is required")
543
+
544
+ duration = int((time.time() - start) * 1000)
545
+
546
+ if issues:
547
+ return PreflightResult(
548
+ step="config",
549
+ status=PreflightStatus.FAILURE,
550
+ message=f"Configuration errors: {'; '.join(issues)}",
551
+ details={"issues": issues},
552
+ duration_ms=duration,
553
+ )
554
+
555
+ return PreflightResult(
556
+ step="config",
557
+ status=PreflightStatus.SUCCESS,
558
+ message="Configuration valid",
559
+ details={
560
+ "client_id": self.config.client_id,
561
+ "scopes": self.config.scopes,
562
+ },
563
+ duration_ms=duration,
564
+ )
565
+
566
+ def _check_endpoint(self, name: str, url: str | None) -> PreflightResult:
567
+ """Check if an endpoint is reachable."""
568
+ start = time.time()
569
+
570
+ if not url:
571
+ return PreflightResult(
572
+ step=name,
573
+ status=PreflightStatus.SKIPPED,
574
+ message=f"{name} endpoint not configured",
575
+ duration_ms=int((time.time() - start) * 1000),
576
+ )
577
+
578
+ try:
579
+ # Just check if endpoint responds (HEAD or GET)
580
+ response = self.http_client.head(url, follow_redirects=True)
581
+ duration = int((time.time() - start) * 1000)
582
+
583
+ # Accept any 2xx, 3xx, 4xx (4xx is expected without proper auth)
584
+ if response.status_code < 500:
585
+ return PreflightResult(
586
+ step=name,
587
+ status=PreflightStatus.SUCCESS,
588
+ message=f"{name} endpoint reachable",
589
+ details={"url": url, "status_code": response.status_code},
590
+ duration_ms=duration,
591
+ )
592
+ return PreflightResult(
593
+ step=name,
594
+ status=PreflightStatus.WARNING,
595
+ message=f"{name} endpoint returned {response.status_code}",
596
+ details={"url": url, "status_code": response.status_code},
597
+ duration_ms=duration,
598
+ )
599
+ except httpx.RequestError as e:
600
+ duration = int((time.time() - start) * 1000)
601
+ return PreflightResult(
602
+ step=name,
603
+ status=PreflightStatus.FAILURE,
604
+ message=f"{name} endpoint unreachable: {e}",
605
+ details={"url": url, "error": str(e)},
606
+ duration_ms=duration,
607
+ )
608
+
609
+ # ─────────────────────────────────────────────────────────────────────────
610
+ # Helpers
611
+ # ─────────────────────────────────────────────────────────────────────────
612
+
613
+ def _parse_error_response(self, response: httpx.Response) -> dict[str, str]:
614
+ """Parse OAuth2 error response."""
615
+ try:
616
+ data = response.json()
617
+ return {
618
+ "error": str(data.get("error", "unknown")),
619
+ "error_description": str(data.get("error_description", response.text)),
620
+ }
621
+ except Exception: # pylint: disable=broad-exception-caught
622
+ # Fallback for non-JSON error responses
623
+ return {"error": "unknown", "error_description": response.text}
624
+
625
+ def _get_trace_config(self) -> tuple[bool, int]:
626
+ """Get trace configuration from kstlib config.
627
+
628
+ Returns:
629
+ Tuple of (pretty_print, max_body_length).
630
+ """
631
+ try:
632
+ from kstlib.config import load_config
633
+
634
+ cfg = load_config()
635
+ # Box allows dot-notation access with defaults
636
+ pretty: bool = cfg.auth.trace.pretty if cfg.auth.trace else _TRACE_PRETTY_DEFAULT
637
+ max_body: int = cfg.auth.trace.max_body_length if cfg.auth.trace else _TRACE_MAX_BODY_DEFAULT
638
+ # Defense in depth: enforce hard limit
639
+ max_body = min(max_body, _TRACE_MAX_BODY_HARD_LIMIT)
640
+ return pretty, max_body
641
+ except Exception: # pylint: disable=broad-exception-caught
642
+ return _TRACE_PRETTY_DEFAULT, _TRACE_MAX_BODY_DEFAULT
643
+
644
+
645
+ __all__ = ["OAuth2Provider"]