kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
|
@@ -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"]
|