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,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"]
|