kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
kstlib/auth/callback.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Local callback server for OAuth2 authorization code flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import secrets
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
from typing_extensions import Self
|
|
16
|
+
|
|
17
|
+
from kstlib.auth.errors import AuthorizationError, CallbackServerError
|
|
18
|
+
from kstlib.logging import TRACE_LEVEL, get_logger
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import types
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
# Defense in depth: maximum timeout regardless of config (10 minutes)
|
|
26
|
+
_CALLBACK_TIMEOUT_HARD_LIMIT = 600
|
|
27
|
+
|
|
28
|
+
# HTML templates for callback responses
|
|
29
|
+
SUCCESS_HTML = """<!DOCTYPE html>
|
|
30
|
+
<html>
|
|
31
|
+
<head>
|
|
32
|
+
<title>Authentication Successful</title>
|
|
33
|
+
<style>
|
|
34
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
35
|
+
display: flex; justify-content: center; align-items: center; height: 100vh;
|
|
36
|
+
margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
|
|
37
|
+
.container {{ background: white; padding: 40px 60px; border-radius: 12px;
|
|
38
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; }}
|
|
39
|
+
h1 {{ color: #22c55e; margin-bottom: 10px; }}
|
|
40
|
+
p {{ color: #666; }}
|
|
41
|
+
.icon {{ font-size: 48px; margin-bottom: 20px; }}
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<div class="container">
|
|
46
|
+
<div class="icon">✓</div>
|
|
47
|
+
<h1>Authentication Successful</h1>
|
|
48
|
+
<p>You can close this window and return to your application.</p>
|
|
49
|
+
</div>
|
|
50
|
+
</body>
|
|
51
|
+
</html>"""
|
|
52
|
+
|
|
53
|
+
ERROR_HTML = """<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>Authentication Failed</title>
|
|
57
|
+
<style>
|
|
58
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
59
|
+
display: flex; justify-content: center; align-items: center; height: 100vh;
|
|
60
|
+
margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
|
|
61
|
+
.container {{ background: white; padding: 40px 60px; border-radius: 12px;
|
|
62
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2); text-align: center; }}
|
|
63
|
+
h1 {{ color: #ef4444; margin-bottom: 10px; }}
|
|
64
|
+
p {{ color: #666; }}
|
|
65
|
+
.error {{ color: #999; font-size: 12px; margin-top: 20px; font-family: monospace; }}
|
|
66
|
+
.icon {{ font-size: 48px; margin-bottom: 20px; }}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<div class="container">
|
|
71
|
+
<div class="icon">✗</div>
|
|
72
|
+
<h1>Authentication Failed</h1>
|
|
73
|
+
<p>{error_description}</p>
|
|
74
|
+
<p class="error">{error_code}</p>
|
|
75
|
+
</div>
|
|
76
|
+
</body>
|
|
77
|
+
</html>"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class CallbackResult:
|
|
82
|
+
"""Result from the OAuth2 callback.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
code: Authorization code (on success).
|
|
86
|
+
state: State parameter for CSRF validation.
|
|
87
|
+
error: OAuth2 error code (on failure).
|
|
88
|
+
error_description: Human-readable error description.
|
|
89
|
+
raw_params: All query parameters from callback.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
code: str | None = None
|
|
93
|
+
state: str | None = None
|
|
94
|
+
error: str | None = None
|
|
95
|
+
error_description: str | None = None
|
|
96
|
+
raw_params: dict[str, list[str]] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def success(self) -> bool:
|
|
100
|
+
"""Check if callback was successful."""
|
|
101
|
+
return self.code is not None and self.error is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
105
|
+
"""HTTP request handler for OAuth2 callback."""
|
|
106
|
+
|
|
107
|
+
# Class-level storage for result (set by server)
|
|
108
|
+
callback_result: CallbackResult | None = None
|
|
109
|
+
callback_path: str = "/callback"
|
|
110
|
+
expected_state: str | None = None
|
|
111
|
+
|
|
112
|
+
def log_message(self, fmt: str, *args: Any) -> None: # pylint: disable=arguments-differ
|
|
113
|
+
"""Suppress default HTTP logging."""
|
|
114
|
+
logger.debug("Callback server: %s", fmt % args)
|
|
115
|
+
|
|
116
|
+
def do_GET(self) -> None:
|
|
117
|
+
"""Handle GET request (OAuth2 callback)."""
|
|
118
|
+
parsed = urlparse(self.path)
|
|
119
|
+
|
|
120
|
+
# Only handle the callback path
|
|
121
|
+
if not parsed.path.rstrip("/").endswith(self.callback_path.rstrip("/")):
|
|
122
|
+
self.send_error(404, "Not Found")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
# Parse query parameters
|
|
126
|
+
params = parse_qs(parsed.query)
|
|
127
|
+
|
|
128
|
+
# Extract OAuth2 parameters
|
|
129
|
+
code = params.get("code", [None])[0]
|
|
130
|
+
state = params.get("state", [None])[0]
|
|
131
|
+
error = params.get("error", [None])[0]
|
|
132
|
+
error_description = params.get("error_description", ["Unknown error"])[0]
|
|
133
|
+
|
|
134
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
135
|
+
# Redact code for security
|
|
136
|
+
redacted_code = f"{code[:8]}...{code[-4:]}" if code and len(code) > 12 else "[short]"
|
|
137
|
+
logger.log(
|
|
138
|
+
TRACE_LEVEL,
|
|
139
|
+
"[CALLBACK] Received: code=%s | state=%s | error=%s",
|
|
140
|
+
redacted_code if code else None,
|
|
141
|
+
state,
|
|
142
|
+
error,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Store result
|
|
146
|
+
CallbackHandler.callback_result = CallbackResult(
|
|
147
|
+
code=code,
|
|
148
|
+
state=state,
|
|
149
|
+
error=error,
|
|
150
|
+
error_description=error_description if error else None,
|
|
151
|
+
raw_params=params,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Send response
|
|
155
|
+
if error:
|
|
156
|
+
self._send_error_response(error, error_description)
|
|
157
|
+
elif code:
|
|
158
|
+
self._send_success_response()
|
|
159
|
+
else:
|
|
160
|
+
self._send_error_response("missing_code", "No authorization code received")
|
|
161
|
+
|
|
162
|
+
def _send_success_response(self) -> None:
|
|
163
|
+
"""Send success HTML response."""
|
|
164
|
+
self.send_response(200)
|
|
165
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
166
|
+
self.end_headers()
|
|
167
|
+
self.wfile.write(SUCCESS_HTML.encode("utf-8"))
|
|
168
|
+
|
|
169
|
+
def _send_error_response(self, error: str, description: str) -> None:
|
|
170
|
+
"""Send error HTML response."""
|
|
171
|
+
self.send_response(400)
|
|
172
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
173
|
+
self.end_headers()
|
|
174
|
+
# Escape user-controlled values to prevent XSS
|
|
175
|
+
safe_html = ERROR_HTML.format(
|
|
176
|
+
error_code=html.escape(error),
|
|
177
|
+
error_description=html.escape(description),
|
|
178
|
+
)
|
|
179
|
+
self.wfile.write(safe_html.encode("utf-8"))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class CallbackServer: # pylint: disable=too-many-instance-attributes
|
|
183
|
+
"""Local HTTP server to receive OAuth2 authorization callbacks.
|
|
184
|
+
|
|
185
|
+
The server runs in a background thread and waits for the IdP to redirect
|
|
186
|
+
the user back with an authorization code.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
>>> server = CallbackServer(port=8400) # doctest: +SKIP
|
|
190
|
+
>>> server.start() # doctest: +SKIP
|
|
191
|
+
>>> # User completes authentication in browser
|
|
192
|
+
>>> result = server.wait_for_callback(timeout=120) # doctest: +SKIP
|
|
193
|
+
>>> if result.success: # doctest: +SKIP
|
|
194
|
+
... print(f"Got code: {result.code}")
|
|
195
|
+
>>> server.stop() # doctest: +SKIP
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
host: str = "127.0.0.1",
|
|
201
|
+
port: int = 8400,
|
|
202
|
+
*,
|
|
203
|
+
path: str = "/callback",
|
|
204
|
+
port_range: tuple[int, int] | None = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Initialize the callback server.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
host: Host to bind to.
|
|
210
|
+
port: Port to listen on.
|
|
211
|
+
path: URL path for callback endpoint.
|
|
212
|
+
port_range: Optional (min, max) port range to try if port is busy.
|
|
213
|
+
"""
|
|
214
|
+
self.host = host
|
|
215
|
+
self.port = port
|
|
216
|
+
self.path = path
|
|
217
|
+
self.port_range = port_range
|
|
218
|
+
self._server: HTTPServer | None = None
|
|
219
|
+
self._thread: threading.Thread | None = None
|
|
220
|
+
self._state: str | None = None
|
|
221
|
+
self._stop_flag: bool = False
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def redirect_uri(self) -> str:
|
|
225
|
+
"""Get the full redirect URI for OAuth2 configuration."""
|
|
226
|
+
return f"http://{self.host}:{self.port}{self.path}"
|
|
227
|
+
|
|
228
|
+
def generate_state(self) -> str:
|
|
229
|
+
"""Generate a cryptographically secure state parameter."""
|
|
230
|
+
self._state = secrets.token_urlsafe(32)
|
|
231
|
+
return self._state
|
|
232
|
+
|
|
233
|
+
def _find_available_port(self) -> int:
|
|
234
|
+
"""Find an available port within the configured range.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
CallbackServerError: If no port is available in the configured range.
|
|
238
|
+
"""
|
|
239
|
+
if self.port_range:
|
|
240
|
+
min_port, max_port = self.port_range
|
|
241
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
242
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] Scanning port range %d-%d", min_port, max_port)
|
|
243
|
+
for port in range(min_port, max_port + 1):
|
|
244
|
+
if self._is_port_available(port):
|
|
245
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
246
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] Found available port %d", port)
|
|
247
|
+
return port
|
|
248
|
+
msg = f"No available port in range {min_port}-{max_port}"
|
|
249
|
+
raise CallbackServerError(msg)
|
|
250
|
+
|
|
251
|
+
if self._is_port_available(self.port):
|
|
252
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
253
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] Port %d is available", self.port)
|
|
254
|
+
return self.port
|
|
255
|
+
|
|
256
|
+
msg = f"Port {self.port} is not available"
|
|
257
|
+
raise CallbackServerError(msg, port=self.port)
|
|
258
|
+
|
|
259
|
+
def _is_port_available(self, port: int) -> bool:
|
|
260
|
+
"""Check if a port is available for binding."""
|
|
261
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
262
|
+
try:
|
|
263
|
+
sock.bind((self.host, port))
|
|
264
|
+
return True
|
|
265
|
+
except OSError:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
def start(self) -> None:
|
|
269
|
+
"""Start the callback server in a background thread.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
CallbackServerError: If server fails to start.
|
|
273
|
+
"""
|
|
274
|
+
if self._server is not None:
|
|
275
|
+
return # Already running
|
|
276
|
+
|
|
277
|
+
# Reset state
|
|
278
|
+
self._stop_flag = False
|
|
279
|
+
CallbackHandler.callback_result = None
|
|
280
|
+
CallbackHandler.callback_path = self.path
|
|
281
|
+
CallbackHandler.expected_state = self._state
|
|
282
|
+
|
|
283
|
+
# Find available port
|
|
284
|
+
self.port = self._find_available_port()
|
|
285
|
+
|
|
286
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
287
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] Binding server to %s:%d", self.host, self.port)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
self._server = HTTPServer((self.host, self.port), CallbackHandler)
|
|
291
|
+
self._server.timeout = 0.5 # Short timeout for responsive shutdown
|
|
292
|
+
# Update port to actual assigned port (important when port=0)
|
|
293
|
+
self.port = self._server.server_address[1]
|
|
294
|
+
|
|
295
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
296
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] Server bound successfully to port %d", self.port)
|
|
297
|
+
except OSError as e:
|
|
298
|
+
msg = f"Failed to start callback server on {self.host}:{self.port}"
|
|
299
|
+
raise CallbackServerError(msg, port=self.port) from e
|
|
300
|
+
|
|
301
|
+
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
302
|
+
self._thread.start()
|
|
303
|
+
logger.info("Callback server started at %s", self.redirect_uri)
|
|
304
|
+
|
|
305
|
+
def _serve(self) -> None:
|
|
306
|
+
"""Server loop running in background thread."""
|
|
307
|
+
if self._server is None:
|
|
308
|
+
return
|
|
309
|
+
while not self._stop_flag and self._server:
|
|
310
|
+
try:
|
|
311
|
+
self._server.handle_request()
|
|
312
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
313
|
+
if not self._stop_flag:
|
|
314
|
+
logger.exception("Error handling callback request")
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
def stop(self) -> None:
|
|
318
|
+
"""Stop the callback server."""
|
|
319
|
+
self._stop_flag = True
|
|
320
|
+
if self._server is not None:
|
|
321
|
+
self._server.server_close()
|
|
322
|
+
self._server = None
|
|
323
|
+
if self._thread is not None:
|
|
324
|
+
self._thread.join(timeout=2)
|
|
325
|
+
self._thread = None
|
|
326
|
+
logger.debug("Callback server stopped")
|
|
327
|
+
|
|
328
|
+
def wait_for_callback(self, timeout: float = 120.0) -> CallbackResult:
|
|
329
|
+
"""Wait for the OAuth2 callback.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
timeout: Maximum time to wait in seconds (capped at 600s).
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
CallbackResult with authorization code or error.
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
CallbackServerError: If timeout expires without callback.
|
|
339
|
+
AuthorizationError: If callback contains an error.
|
|
340
|
+
"""
|
|
341
|
+
# Defense in depth: cap timeout regardless of config
|
|
342
|
+
timeout = min(timeout, _CALLBACK_TIMEOUT_HARD_LIMIT)
|
|
343
|
+
start_time = time.time()
|
|
344
|
+
while time.time() - start_time < timeout:
|
|
345
|
+
if CallbackHandler.callback_result is not None:
|
|
346
|
+
result = CallbackHandler.callback_result
|
|
347
|
+
CallbackHandler.callback_result = None # Clear for next use
|
|
348
|
+
|
|
349
|
+
# Validate state if we generated one
|
|
350
|
+
if self._state and result.state != self._state:
|
|
351
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
352
|
+
logger.log(
|
|
353
|
+
TRACE_LEVEL,
|
|
354
|
+
"[CALLBACK] State mismatch: expected=%s | received=%s",
|
|
355
|
+
self._state,
|
|
356
|
+
result.state,
|
|
357
|
+
)
|
|
358
|
+
raise AuthorizationError(
|
|
359
|
+
"State mismatch - possible CSRF attack",
|
|
360
|
+
error_code="state_mismatch",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
364
|
+
logger.log(TRACE_LEVEL, "[CALLBACK] State validated successfully")
|
|
365
|
+
|
|
366
|
+
if result.error:
|
|
367
|
+
raise AuthorizationError(
|
|
368
|
+
result.error_description or result.error,
|
|
369
|
+
error_code=result.error,
|
|
370
|
+
error_description=result.error_description,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
time.sleep(0.1)
|
|
376
|
+
|
|
377
|
+
msg = f"Timeout waiting for OAuth2 callback after {timeout}s"
|
|
378
|
+
raise CallbackServerError(msg)
|
|
379
|
+
|
|
380
|
+
def __enter__(self) -> Self:
|
|
381
|
+
"""Context manager entry - start server."""
|
|
382
|
+
self.start()
|
|
383
|
+
return self
|
|
384
|
+
|
|
385
|
+
def __exit__(
|
|
386
|
+
self,
|
|
387
|
+
exc_type: type[BaseException] | None,
|
|
388
|
+
exc_val: BaseException | None,
|
|
389
|
+
exc_tb: types.TracebackType | None,
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Context manager exit - stop server."""
|
|
392
|
+
self.stop()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
__all__ = [
|
|
396
|
+
"CallbackHandler",
|
|
397
|
+
"CallbackResult",
|
|
398
|
+
"CallbackServer",
|
|
399
|
+
]
|