cpkit 0.1.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.
- cpkit/README.md +82 -0
- cpkit/__init__.py +42 -0
- cpkit/admin.py +53 -0
- cpkit/app.py +130 -0
- cpkit/audit/README.md +33 -0
- cpkit/audit/__init__.py +44 -0
- cpkit/audit/events_service.py +36 -0
- cpkit/audit/recorder.py +240 -0
- cpkit/audit/repository.py +64 -0
- cpkit/audit/router.py +46 -0
- cpkit/audit/service.py +34 -0
- cpkit/audit/types.py +38 -0
- cpkit/auth/README.md +38 -0
- cpkit/auth/__init__.py +123 -0
- cpkit/auth/api_key_router.py +52 -0
- cpkit/auth/api_key_service.py +143 -0
- cpkit/auth/api_keys.py +143 -0
- cpkit/auth/bundle.py +113 -0
- cpkit/auth/claims.py +37 -0
- cpkit/auth/config.py +162 -0
- cpkit/auth/dependencies.py +155 -0
- cpkit/auth/oidc.py +703 -0
- cpkit/auth/redirects.py +12 -0
- cpkit/auth/repositories.py +181 -0
- cpkit/auth/router.py +214 -0
- cpkit/auth/secrets.py +78 -0
- cpkit/auth/types.py +49 -0
- cpkit/bundle.py +252 -0
- cpkit/cli/README.md +21 -0
- cpkit/cli/__init__.py +14 -0
- cpkit/cli/__main__.py +5 -0
- cpkit/cli/base.py +200 -0
- cpkit/cli/schema.py +30 -0
- cpkit/cli/server.py +22 -0
- cpkit/config/README.md +13 -0
- cpkit/config/__init__.py +9 -0
- cpkit/config/env.py +31 -0
- cpkit/db/README.md +24 -0
- cpkit/db/__init__.py +23 -0
- cpkit/db/postgres.py +252 -0
- cpkit/dependencies.py +73 -0
- cpkit/errors/README.md +22 -0
- cpkit/errors/__init__.py +35 -0
- cpkit/errors/http.py +45 -0
- cpkit/errors/repository.py +32 -0
- cpkit/errors/service.py +74 -0
- cpkit/jobs/README.md +29 -0
- cpkit/jobs/__init__.py +49 -0
- cpkit/jobs/maintenance.py +15 -0
- cpkit/jobs/repository.py +306 -0
- cpkit/jobs/router.py +105 -0
- cpkit/jobs/service.py +138 -0
- cpkit/jobs/types.py +67 -0
- cpkit/jobs/worker.py +200 -0
- cpkit/logging/README.md +19 -0
- cpkit/logging/__init__.py +13 -0
- cpkit/logging/context.py +29 -0
- cpkit/logging/middleware.py +55 -0
- cpkit/logging/setup.py +81 -0
- cpkit/playbooks/README.md +25 -0
- cpkit/playbooks/__init__.py +40 -0
- cpkit/playbooks/ansible.py +359 -0
- cpkit/playbooks/repository.py +95 -0
- cpkit/playbooks/router.py +95 -0
- cpkit/playbooks/service.py +232 -0
- cpkit/playbooks/types.py +43 -0
- cpkit/repository.py +63 -0
- cpkit/resources/__init__.py +17 -0
- cpkit/resources/ddl.sql +150 -0
- cpkit/settings/README.md +22 -0
- cpkit/settings/__init__.py +18 -0
- cpkit/settings/keys.py +30 -0
- cpkit/settings/repository.py +108 -0
- cpkit/settings/router.py +62 -0
- cpkit/settings/service.py +105 -0
- cpkit/settings/types.py +25 -0
- cpkit/time.py +4 -0
- cpkit/webapp/README.md +71 -0
- cpkit/webapp/__init__.py +12 -0
- cpkit/webapp/index.html +970 -0
- cpkit/webapp/script.js +1690 -0
- cpkit/webapp/style.css +1561 -0
- cpkit-0.1.0.dist-info/METADATA +105 -0
- cpkit-0.1.0.dist-info/RECORD +90 -0
- cpkit-0.1.0.dist-info/WHEEL +4 -0
- cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- resources/README.md +16 -0
- resources/repository_maintenance_guide.md +90 -0
- resources/webapp_extension_guide.md +706 -0
- resources/webapp_extension_template.js +130 -0
cpkit/auth/oidc.py
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""Reusable OIDC provider client mechanics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import jwt
|
|
12
|
+
from fastapi import HTTPException, status
|
|
13
|
+
|
|
14
|
+
from .api_keys import APIKeyAuthenticationError, APIKeyAuthenticator
|
|
15
|
+
from .claims import claims_groups, jsonable_role_groups
|
|
16
|
+
from .config import OIDCConfig
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OIDCAuthenticationError(Exception):
|
|
20
|
+
"""Raised when an OIDC token cannot be authenticated."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, detail: str) -> None:
|
|
23
|
+
self.detail = detail
|
|
24
|
+
super().__init__(detail)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OIDCSessionRepository(Protocol):
|
|
28
|
+
"""Repository-like object that persists OIDC server-side sessions."""
|
|
29
|
+
|
|
30
|
+
def get_oidc_session(self, session_id: str) -> Any | None: ...
|
|
31
|
+
|
|
32
|
+
def delete_oidc_session(self, session_id: str) -> None: ...
|
|
33
|
+
|
|
34
|
+
def update_oidc_session(
|
|
35
|
+
self,
|
|
36
|
+
session_id: str,
|
|
37
|
+
*,
|
|
38
|
+
encrypted_id_token: bytes,
|
|
39
|
+
encrypted_refresh_token: bytes | None,
|
|
40
|
+
token_expires_at: datetime,
|
|
41
|
+
) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OIDCProviderClient:
|
|
45
|
+
"""Load OIDC provider metadata and exchange OAuth/OIDC tokens."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: Any) -> None:
|
|
48
|
+
self.config = config
|
|
49
|
+
self._metadata: dict[str, Any] | None = None
|
|
50
|
+
self._jwks: dict[str, Any] | None = None
|
|
51
|
+
self._meta_loaded_at = 0.0
|
|
52
|
+
self._jwks_loaded_at = 0.0
|
|
53
|
+
self._cache_ttl_seconds = getattr(config, "cache_ttl_seconds", 300)
|
|
54
|
+
|
|
55
|
+
def update_provider_config(self, config: Any, *, clear_cache: bool = False) -> None:
|
|
56
|
+
self.config = config
|
|
57
|
+
self._cache_ttl_seconds = getattr(config, "cache_ttl_seconds", 300)
|
|
58
|
+
if clear_cache:
|
|
59
|
+
self.clear_provider_cache()
|
|
60
|
+
|
|
61
|
+
def clear_provider_cache(self) -> None:
|
|
62
|
+
self._metadata = None
|
|
63
|
+
self._jwks = None
|
|
64
|
+
self._meta_loaded_at = 0.0
|
|
65
|
+
self._jwks_loaded_at = 0.0
|
|
66
|
+
|
|
67
|
+
def _http_json(
|
|
68
|
+
self,
|
|
69
|
+
url: str,
|
|
70
|
+
*,
|
|
71
|
+
method: str = "GET",
|
|
72
|
+
data: dict[str, str] | None = None,
|
|
73
|
+
headers: dict[str, str] | None = None,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
req_headers = {"Accept": "application/json"}
|
|
76
|
+
if headers:
|
|
77
|
+
req_headers.update(headers)
|
|
78
|
+
|
|
79
|
+
payload = None
|
|
80
|
+
if data is not None:
|
|
81
|
+
payload = urllib.parse.urlencode(data).encode("utf-8")
|
|
82
|
+
req_headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
83
|
+
|
|
84
|
+
req = urllib.request.Request(
|
|
85
|
+
url,
|
|
86
|
+
data=payload,
|
|
87
|
+
headers=req_headers,
|
|
88
|
+
method=method,
|
|
89
|
+
)
|
|
90
|
+
with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310
|
|
91
|
+
raw = resp.read().decode("utf-8")
|
|
92
|
+
parsed = json.loads(raw)
|
|
93
|
+
if not isinstance(parsed, dict):
|
|
94
|
+
raise RuntimeError(f"Expected JSON object from {url}")
|
|
95
|
+
return parsed
|
|
96
|
+
|
|
97
|
+
def get_metadata(self) -> dict[str, Any]:
|
|
98
|
+
"""Return cached OIDC discovery metadata, refreshing it when needed."""
|
|
99
|
+
if (
|
|
100
|
+
self._metadata
|
|
101
|
+
and (time.time() - self._meta_loaded_at) < self._cache_ttl_seconds
|
|
102
|
+
):
|
|
103
|
+
return self._metadata
|
|
104
|
+
metadata_url = f"{self.config.issuer_url}/.well-known/openid-configuration"
|
|
105
|
+
self._metadata = self._http_json(metadata_url)
|
|
106
|
+
self._meta_loaded_at = time.time()
|
|
107
|
+
return self._metadata
|
|
108
|
+
|
|
109
|
+
def get_jwks(self) -> dict[str, Any]:
|
|
110
|
+
"""Return cached provider signing keys, refreshing them when needed."""
|
|
111
|
+
if (
|
|
112
|
+
self._jwks
|
|
113
|
+
and (time.time() - self._jwks_loaded_at) < self._cache_ttl_seconds
|
|
114
|
+
):
|
|
115
|
+
return self._jwks
|
|
116
|
+
|
|
117
|
+
metadata = self.get_metadata()
|
|
118
|
+
jwks_uri = str(metadata.get("jwks_uri") or "")
|
|
119
|
+
if not jwks_uri:
|
|
120
|
+
raise RuntimeError("OIDC provider metadata missing 'jwks_uri'")
|
|
121
|
+
|
|
122
|
+
self._jwks = self._http_json(jwks_uri)
|
|
123
|
+
self._jwks_loaded_at = time.time()
|
|
124
|
+
return self._jwks
|
|
125
|
+
|
|
126
|
+
def build_authorization_url(self, redirect_uri: str, state: str, nonce: str) -> str:
|
|
127
|
+
"""Build the provider authorization URL for starting login."""
|
|
128
|
+
metadata = self.get_metadata()
|
|
129
|
+
auth_endpoint = str(metadata.get("authorization_endpoint") or "")
|
|
130
|
+
if not auth_endpoint:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"OIDC provider metadata missing 'authorization_endpoint'"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
params = {
|
|
136
|
+
"response_type": "code",
|
|
137
|
+
"client_id": self.config.client_id,
|
|
138
|
+
"redirect_uri": redirect_uri,
|
|
139
|
+
"scope": self.config.scopes,
|
|
140
|
+
"state": state,
|
|
141
|
+
"nonce": nonce,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if self.config.audience:
|
|
145
|
+
params["audience"] = self.config.audience
|
|
146
|
+
|
|
147
|
+
params.update(self.config.extra_auth_params())
|
|
148
|
+
|
|
149
|
+
return f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
|
|
150
|
+
|
|
151
|
+
def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]:
|
|
152
|
+
"""Exchange an OIDC authorization code for a token response."""
|
|
153
|
+
metadata = self.get_metadata()
|
|
154
|
+
token_endpoint = str(metadata.get("token_endpoint") or "")
|
|
155
|
+
if not token_endpoint:
|
|
156
|
+
raise RuntimeError("OIDC provider metadata missing 'token_endpoint'")
|
|
157
|
+
|
|
158
|
+
payload = {
|
|
159
|
+
"grant_type": "authorization_code",
|
|
160
|
+
"code": code,
|
|
161
|
+
"client_id": self.config.client_id,
|
|
162
|
+
"client_secret": self.config.client_secret,
|
|
163
|
+
"redirect_uri": redirect_uri,
|
|
164
|
+
}
|
|
165
|
+
payload.update(self.config.extra_auth_params())
|
|
166
|
+
|
|
167
|
+
return self._http_json(token_endpoint, method="POST", data=payload)
|
|
168
|
+
|
|
169
|
+
def refresh_tokens(self, refresh_token: str) -> dict[str, Any]:
|
|
170
|
+
"""Exchange a refresh token for fresh token material."""
|
|
171
|
+
metadata = self.get_metadata()
|
|
172
|
+
token_endpoint = str(metadata.get("token_endpoint") or "")
|
|
173
|
+
if not token_endpoint:
|
|
174
|
+
raise RuntimeError("OIDC provider metadata missing 'token_endpoint'")
|
|
175
|
+
|
|
176
|
+
payload = {
|
|
177
|
+
"grant_type": "refresh_token",
|
|
178
|
+
"refresh_token": refresh_token,
|
|
179
|
+
"client_id": self.config.client_id,
|
|
180
|
+
"client_secret": self.config.client_secret,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return self._http_json(token_endpoint, method="POST", data=payload)
|
|
184
|
+
|
|
185
|
+
def select_jwk_key(self, token: str) -> Any:
|
|
186
|
+
"""Return the provider signing key that matches a JWT header."""
|
|
187
|
+
header = jwt.get_unverified_header(token)
|
|
188
|
+
kid = header.get("kid")
|
|
189
|
+
if not kid:
|
|
190
|
+
raise OIDCAuthenticationError("Token header is missing 'kid'")
|
|
191
|
+
|
|
192
|
+
keys = self.get_jwks().get("keys", [])
|
|
193
|
+
for jwk in keys:
|
|
194
|
+
if jwk.get("kid") == kid:
|
|
195
|
+
return jwt.PyJWK.from_dict(jwk).key
|
|
196
|
+
|
|
197
|
+
self._jwks = None
|
|
198
|
+
keys = self.get_jwks().get("keys", [])
|
|
199
|
+
for jwk in keys:
|
|
200
|
+
if jwk.get("kid") == kid:
|
|
201
|
+
return jwt.PyJWK.from_dict(jwk).key
|
|
202
|
+
|
|
203
|
+
raise OIDCAuthenticationError("Unable to find a matching JWKS key for token")
|
|
204
|
+
|
|
205
|
+
def validate_jwt(
|
|
206
|
+
self,
|
|
207
|
+
token: str,
|
|
208
|
+
*,
|
|
209
|
+
expected_nonce: str | None = None,
|
|
210
|
+
strict_client_audience: bool = False,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
"""Validate a JWT against the provider configuration and optional nonce."""
|
|
213
|
+
key = self.select_jwk_key(token)
|
|
214
|
+
|
|
215
|
+
options = {
|
|
216
|
+
"verify_signature": True,
|
|
217
|
+
"verify_exp": True,
|
|
218
|
+
"verify_iat": True,
|
|
219
|
+
"verify_nbf": True,
|
|
220
|
+
"verify_iss": True,
|
|
221
|
+
"verify_aud": strict_client_audience
|
|
222
|
+
or self.config.verify_audience
|
|
223
|
+
or bool(self.config.audience),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
audience = None
|
|
227
|
+
if strict_client_audience:
|
|
228
|
+
audience = self.config.client_id
|
|
229
|
+
elif self.config.audience:
|
|
230
|
+
audience = self.config.audience
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
claims = jwt.decode(
|
|
234
|
+
token,
|
|
235
|
+
key=key,
|
|
236
|
+
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
|
|
237
|
+
issuer=self.config.issuer_url,
|
|
238
|
+
audience=audience,
|
|
239
|
+
options=options,
|
|
240
|
+
)
|
|
241
|
+
except jwt.PyJWTError as exc:
|
|
242
|
+
raise OIDCAuthenticationError(f"Invalid token: {exc}") from exc
|
|
243
|
+
|
|
244
|
+
if expected_nonce is not None and claims.get("nonce") != expected_nonce:
|
|
245
|
+
raise OIDCAuthenticationError("Invalid token nonce")
|
|
246
|
+
|
|
247
|
+
return claims
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def token_expires_at(claims: dict[str, Any]) -> datetime:
|
|
251
|
+
"""Return the UTC expiration timestamp encoded in JWT claims."""
|
|
252
|
+
raw_exp = claims.get("exp")
|
|
253
|
+
if raw_exp is None:
|
|
254
|
+
raise OIDCAuthenticationError("Token is missing 'exp'.")
|
|
255
|
+
try:
|
|
256
|
+
return datetime.fromtimestamp(float(raw_exp), tz=timezone.utc)
|
|
257
|
+
except (TypeError, ValueError, OSError, OverflowError) as exc:
|
|
258
|
+
raise OIDCAuthenticationError("Token has an invalid 'exp' claim.") from exc
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class OIDCSessionManager:
|
|
262
|
+
"""Manage encrypted server-side OIDC sessions and token refresh."""
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
provider_client: OIDCProviderClient,
|
|
267
|
+
*,
|
|
268
|
+
encrypt_secret: Callable[[bytes | str], bytes],
|
|
269
|
+
decrypt_secret: Callable[[bytes | str], bytes],
|
|
270
|
+
session_record_factory: Callable[..., Any],
|
|
271
|
+
) -> None:
|
|
272
|
+
self.provider_client = provider_client
|
|
273
|
+
self.encrypt_secret = encrypt_secret
|
|
274
|
+
self.decrypt_secret = decrypt_secret
|
|
275
|
+
self.session_record_factory = session_record_factory
|
|
276
|
+
|
|
277
|
+
def build_session_record(
|
|
278
|
+
self,
|
|
279
|
+
session_id: str,
|
|
280
|
+
*,
|
|
281
|
+
id_token: str,
|
|
282
|
+
refresh_token: str | None,
|
|
283
|
+
claims: dict[str, Any],
|
|
284
|
+
) -> Any:
|
|
285
|
+
"""Return an encrypted server-side session representation."""
|
|
286
|
+
now = datetime.now(timezone.utc)
|
|
287
|
+
return self.session_record_factory(
|
|
288
|
+
session_id=session_id,
|
|
289
|
+
encrypted_id_token=self.encrypt_secret(id_token),
|
|
290
|
+
encrypted_refresh_token=(
|
|
291
|
+
self.encrypt_secret(refresh_token) if refresh_token else None
|
|
292
|
+
),
|
|
293
|
+
token_expires_at=OIDCProviderClient.token_expires_at(claims),
|
|
294
|
+
session_expires_at=now
|
|
295
|
+
+ timedelta(
|
|
296
|
+
seconds=self.provider_client.config.session_max_age_seconds,
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def claims_from_session(
|
|
301
|
+
self,
|
|
302
|
+
repo: OIDCSessionRepository,
|
|
303
|
+
session_id: str,
|
|
304
|
+
*,
|
|
305
|
+
authorize_claims: Callable[[dict[str, Any]], dict[str, Any]],
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
"""Load a session and refresh token material when needed."""
|
|
308
|
+
session = repo.get_oidc_session(session_id)
|
|
309
|
+
if session is None:
|
|
310
|
+
raise OIDCAuthenticationError("Not authenticated.")
|
|
311
|
+
|
|
312
|
+
now = datetime.now(timezone.utc)
|
|
313
|
+
if session.session_expires_at <= now:
|
|
314
|
+
repo.delete_oidc_session(session_id)
|
|
315
|
+
raise OIDCAuthenticationError("OIDC session expired.")
|
|
316
|
+
|
|
317
|
+
refresh_deadline = session.token_expires_at - timedelta(
|
|
318
|
+
seconds=self.provider_client.config.refresh_leeway_seconds
|
|
319
|
+
)
|
|
320
|
+
if refresh_deadline <= now:
|
|
321
|
+
claims = self.refresh_session(
|
|
322
|
+
repo,
|
|
323
|
+
session,
|
|
324
|
+
authorize_claims=authorize_claims,
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
try:
|
|
328
|
+
id_token = self.decrypt_secret(session.encrypted_id_token).decode(
|
|
329
|
+
"utf-8"
|
|
330
|
+
)
|
|
331
|
+
claims = OIDCProviderClient.validate_jwt(
|
|
332
|
+
self.provider_client,
|
|
333
|
+
id_token,
|
|
334
|
+
strict_client_audience=True,
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
claims = self.refresh_session(
|
|
338
|
+
repo,
|
|
339
|
+
session,
|
|
340
|
+
authorize_claims=authorize_claims,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
claims = authorize_claims(claims)
|
|
344
|
+
claims["_session_id"] = session_id
|
|
345
|
+
claims["auth_type"] = "oidc"
|
|
346
|
+
return claims
|
|
347
|
+
|
|
348
|
+
def refresh_session(
|
|
349
|
+
self,
|
|
350
|
+
repo: OIDCSessionRepository,
|
|
351
|
+
session: Any,
|
|
352
|
+
*,
|
|
353
|
+
authorize_claims: Callable[[dict[str, Any]], dict[str, Any]],
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
"""Refresh an OIDC session using its stored refresh token."""
|
|
356
|
+
if not session.encrypted_refresh_token:
|
|
357
|
+
repo.delete_oidc_session(session.session_id)
|
|
358
|
+
raise OIDCAuthenticationError("OIDC session expired.")
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
refresh_token = self.decrypt_secret(session.encrypted_refresh_token).decode(
|
|
362
|
+
"utf-8"
|
|
363
|
+
)
|
|
364
|
+
token_payload = self.provider_client.refresh_tokens(refresh_token)
|
|
365
|
+
except Exception:
|
|
366
|
+
repo.delete_oidc_session(session.session_id)
|
|
367
|
+
raise OIDCAuthenticationError("OIDC refresh failed. Please sign in again.")
|
|
368
|
+
|
|
369
|
+
id_token = token_payload.get("id_token")
|
|
370
|
+
if not id_token or not isinstance(id_token, str):
|
|
371
|
+
repo.delete_oidc_session(session.session_id)
|
|
372
|
+
raise OIDCAuthenticationError(
|
|
373
|
+
"OIDC refresh response missing id_token. Please sign in again."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
claims = OIDCProviderClient.validate_jwt(
|
|
377
|
+
self.provider_client,
|
|
378
|
+
id_token,
|
|
379
|
+
strict_client_audience=True,
|
|
380
|
+
)
|
|
381
|
+
authorize_claims(claims)
|
|
382
|
+
|
|
383
|
+
next_refresh_token = token_payload.get("refresh_token")
|
|
384
|
+
effective_refresh_token = (
|
|
385
|
+
next_refresh_token
|
|
386
|
+
if isinstance(next_refresh_token, str) and next_refresh_token
|
|
387
|
+
else refresh_token
|
|
388
|
+
)
|
|
389
|
+
repo.update_oidc_session(
|
|
390
|
+
session.session_id,
|
|
391
|
+
encrypted_id_token=self.encrypt_secret(id_token),
|
|
392
|
+
encrypted_refresh_token=self.encrypt_secret(effective_refresh_token),
|
|
393
|
+
token_expires_at=OIDCProviderClient.token_expires_at(claims),
|
|
394
|
+
)
|
|
395
|
+
return claims
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class OIDCManager(OIDCProviderClient):
|
|
399
|
+
"""Coordinate OIDC config, sessions, API keys, and claim authorization."""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self,
|
|
403
|
+
*,
|
|
404
|
+
config: OIDCConfig | None = None,
|
|
405
|
+
encrypt_secret: Callable[[bytes | str], bytes],
|
|
406
|
+
decrypt_secret: Callable[[bytes | str], bytes],
|
|
407
|
+
session_record_factory: Callable[..., Any],
|
|
408
|
+
session_cookie_name: str | None = None,
|
|
409
|
+
missing_api_key_headers_detail: str = (
|
|
410
|
+
"Access key, signature, and timestamp are required."
|
|
411
|
+
),
|
|
412
|
+
auth_disabled_claims: dict[str, Any] | None = None,
|
|
413
|
+
validate_secret_crypto_config: Callable[[], None] | None = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
super().__init__(config or OIDCConfig())
|
|
416
|
+
self.sessions = OIDCSessionManager(
|
|
417
|
+
self,
|
|
418
|
+
encrypt_secret=encrypt_secret,
|
|
419
|
+
decrypt_secret=decrypt_secret,
|
|
420
|
+
session_record_factory=session_record_factory,
|
|
421
|
+
)
|
|
422
|
+
self.api_keys = APIKeyAuthenticator(decrypt_secret=decrypt_secret)
|
|
423
|
+
self.session_cookie_name = session_cookie_name
|
|
424
|
+
self.missing_api_key_headers_detail = missing_api_key_headers_detail
|
|
425
|
+
self.auth_disabled_claims = auth_disabled_claims or {
|
|
426
|
+
"sub": "anonymous",
|
|
427
|
+
"auth_disabled": True,
|
|
428
|
+
}
|
|
429
|
+
self.validate_secret_crypto_config = validate_secret_crypto_config
|
|
430
|
+
self._config_loaded_at = 0.0
|
|
431
|
+
self._config_cache_ttl_seconds = 300
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def enabled(self) -> bool:
|
|
435
|
+
"""Expose whether OIDC-backed authentication is enabled."""
|
|
436
|
+
return self.config.enabled
|
|
437
|
+
|
|
438
|
+
def load_config(self, repo: Any, *, force: bool = False) -> None:
|
|
439
|
+
"""Load framework OIDC configuration from a repository-like object."""
|
|
440
|
+
now = time.time()
|
|
441
|
+
if (
|
|
442
|
+
not force
|
|
443
|
+
and self._config_loaded_at
|
|
444
|
+
and (now - self._config_loaded_at) < self._config_cache_ttl_seconds
|
|
445
|
+
):
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
new_config = OIDCConfig.from_repo(repo)
|
|
449
|
+
self.update_provider_config(
|
|
450
|
+
new_config,
|
|
451
|
+
clear_cache=self.config != new_config,
|
|
452
|
+
)
|
|
453
|
+
self._config_loaded_at = now
|
|
454
|
+
|
|
455
|
+
def validate_config(
|
|
456
|
+
self,
|
|
457
|
+
repo: Any,
|
|
458
|
+
*,
|
|
459
|
+
validate_secret_crypto_config: Callable[[], None] | None = None,
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Validate auth configuration at startup."""
|
|
462
|
+
self.load_config(repo, force=True)
|
|
463
|
+
self.config.validate()
|
|
464
|
+
crypto_validator = (
|
|
465
|
+
validate_secret_crypto_config or self.validate_secret_crypto_config
|
|
466
|
+
)
|
|
467
|
+
if crypto_validator is not None:
|
|
468
|
+
crypto_validator()
|
|
469
|
+
|
|
470
|
+
def validate_jwt(
|
|
471
|
+
self,
|
|
472
|
+
token: str,
|
|
473
|
+
*,
|
|
474
|
+
expected_nonce: str | None = None,
|
|
475
|
+
strict_client_audience: bool = False,
|
|
476
|
+
) -> dict[str, Any]:
|
|
477
|
+
"""Validate a JWT and translate auth failures into FastAPI errors."""
|
|
478
|
+
try:
|
|
479
|
+
return OIDCProviderClient.validate_jwt(
|
|
480
|
+
self,
|
|
481
|
+
token,
|
|
482
|
+
expected_nonce=expected_nonce,
|
|
483
|
+
strict_client_audience=strict_client_audience,
|
|
484
|
+
)
|
|
485
|
+
except OIDCAuthenticationError as exc:
|
|
486
|
+
raise HTTPException(
|
|
487
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
488
|
+
detail=exc.detail,
|
|
489
|
+
) from exc
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def token_expires_at(claims: dict[str, Any]) -> datetime:
|
|
493
|
+
"""Return a JWT expiration timestamp and translate auth failures."""
|
|
494
|
+
try:
|
|
495
|
+
return OIDCProviderClient.token_expires_at(claims)
|
|
496
|
+
except OIDCAuthenticationError as exc:
|
|
497
|
+
raise HTTPException(
|
|
498
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
499
|
+
detail=exc.detail,
|
|
500
|
+
) from exc
|
|
501
|
+
|
|
502
|
+
def build_session_record(
|
|
503
|
+
self,
|
|
504
|
+
session_id: str,
|
|
505
|
+
*,
|
|
506
|
+
id_token: str,
|
|
507
|
+
refresh_token: str | None,
|
|
508
|
+
claims: dict[str, Any],
|
|
509
|
+
) -> Any:
|
|
510
|
+
"""Return the encrypted server-side session representation for a login."""
|
|
511
|
+
return self.sessions.build_session_record(
|
|
512
|
+
session_id,
|
|
513
|
+
id_token=id_token,
|
|
514
|
+
refresh_token=refresh_token,
|
|
515
|
+
claims=claims,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def ensure_authorized(self, claims: dict[str, Any]) -> dict[str, Any]:
|
|
519
|
+
"""Ensure the caller belongs to at least one configured application group."""
|
|
520
|
+
if claims.get("auth_disabled"):
|
|
521
|
+
return claims
|
|
522
|
+
|
|
523
|
+
groups_claim_name = str(
|
|
524
|
+
claims.get("_groups_claim_name", self.config.groups_claim_name)
|
|
525
|
+
)
|
|
526
|
+
user_groups = claims_groups(claims, groups_claim_name)
|
|
527
|
+
if not user_groups:
|
|
528
|
+
raise HTTPException(
|
|
529
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
530
|
+
detail=f"Forbidden: no groups found in claim '{groups_claim_name}'.",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if self.config.authorized_groups.isdisjoint(user_groups):
|
|
534
|
+
raise HTTPException(
|
|
535
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
536
|
+
detail="Forbidden: user is not in any allowed group.",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return claims
|
|
540
|
+
|
|
541
|
+
def enrich_claims(self, claims: dict[str, Any]) -> dict[str, Any]:
|
|
542
|
+
"""Add metadata that helps an application render auth state."""
|
|
543
|
+
payload = dict(claims)
|
|
544
|
+
payload["_groups_claim_name"] = str(
|
|
545
|
+
claims.get("_groups_claim_name", self.config.groups_claim_name)
|
|
546
|
+
)
|
|
547
|
+
effective_role_groups = (
|
|
548
|
+
claims.get("_role_groups")
|
|
549
|
+
if isinstance(claims.get("_role_groups"), dict)
|
|
550
|
+
else self.config.role_groups
|
|
551
|
+
)
|
|
552
|
+
payload["_role_groups"] = jsonable_role_groups(effective_role_groups)
|
|
553
|
+
|
|
554
|
+
if self.session_cookie_name:
|
|
555
|
+
existing_meta = (
|
|
556
|
+
claims.get("_cp") if isinstance(claims.get("_cp"), dict) else {}
|
|
557
|
+
)
|
|
558
|
+
payload["_cp"] = {
|
|
559
|
+
**existing_meta,
|
|
560
|
+
"display_name_claim": self.config.ui_username_claim,
|
|
561
|
+
"session_cookie_name": self.session_cookie_name,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return payload
|
|
565
|
+
|
|
566
|
+
def ensure_any_role(self, claims: dict[str, Any], *roles: Any) -> dict[str, Any]:
|
|
567
|
+
"""Ensure the caller has at least one of the requested application roles."""
|
|
568
|
+
if claims.get("auth_disabled"):
|
|
569
|
+
return claims
|
|
570
|
+
|
|
571
|
+
groups_claim_name = str(
|
|
572
|
+
claims.get("_groups_claim_name", self.config.groups_claim_name)
|
|
573
|
+
)
|
|
574
|
+
user_groups = claims_groups(claims, groups_claim_name)
|
|
575
|
+
if not user_groups:
|
|
576
|
+
raise HTTPException(
|
|
577
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
578
|
+
detail=f"Forbidden: no groups found in claim '{groups_claim_name}'.",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
effective_roles = (
|
|
582
|
+
claims.get("_role_groups")
|
|
583
|
+
if isinstance(claims.get("_role_groups"), dict)
|
|
584
|
+
else self.config.role_groups
|
|
585
|
+
)
|
|
586
|
+
for role in roles:
|
|
587
|
+
role_name = _role_value(role)
|
|
588
|
+
role_groups = effective_roles.get(role, set()) or effective_roles.get(
|
|
589
|
+
role_name, set()
|
|
590
|
+
)
|
|
591
|
+
if role_groups and not claims_groups(
|
|
592
|
+
{groups_claim_name: role_groups},
|
|
593
|
+
groups_claim_name,
|
|
594
|
+
).isdisjoint(user_groups):
|
|
595
|
+
return claims
|
|
596
|
+
|
|
597
|
+
role_list = ", ".join(_role_value(role) for role in roles)
|
|
598
|
+
raise HTTPException(
|
|
599
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
600
|
+
detail=f"Forbidden: requires one of roles [{role_list}].",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
async def validate_api_key(
|
|
604
|
+
self,
|
|
605
|
+
request: Any,
|
|
606
|
+
repo: Any,
|
|
607
|
+
access_key: str,
|
|
608
|
+
signature: str,
|
|
609
|
+
timestamp: str,
|
|
610
|
+
) -> dict[str, Any]:
|
|
611
|
+
"""Authenticate an API request using HMAC-signed API key headers."""
|
|
612
|
+
try:
|
|
613
|
+
return await self.api_keys.authenticate_request(
|
|
614
|
+
request,
|
|
615
|
+
repo,
|
|
616
|
+
access_key=access_key,
|
|
617
|
+
signature=signature,
|
|
618
|
+
timestamp=timestamp,
|
|
619
|
+
max_age_seconds=self.config.api_key_signature_ttl_seconds,
|
|
620
|
+
)
|
|
621
|
+
except APIKeyAuthenticationError as exc:
|
|
622
|
+
raise HTTPException(
|
|
623
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
624
|
+
detail=exc.detail,
|
|
625
|
+
) from exc
|
|
626
|
+
|
|
627
|
+
async def current_claims(
|
|
628
|
+
self,
|
|
629
|
+
request: Any,
|
|
630
|
+
repo: Any,
|
|
631
|
+
*,
|
|
632
|
+
session_token: str | None = None,
|
|
633
|
+
access_key: str | None = None,
|
|
634
|
+
signature: str | None = None,
|
|
635
|
+
timestamp: str | None = None,
|
|
636
|
+
) -> dict[str, Any]:
|
|
637
|
+
"""Resolve request claims from API-key headers or an OIDC session."""
|
|
638
|
+
if access_key or signature or timestamp:
|
|
639
|
+
if not access_key or not signature or not timestamp:
|
|
640
|
+
raise HTTPException(
|
|
641
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
642
|
+
detail=self.missing_api_key_headers_detail,
|
|
643
|
+
)
|
|
644
|
+
return await self.validate_api_key(
|
|
645
|
+
request,
|
|
646
|
+
repo,
|
|
647
|
+
access_key,
|
|
648
|
+
signature,
|
|
649
|
+
timestamp,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
if not self.enabled:
|
|
653
|
+
return dict(self.auth_disabled_claims)
|
|
654
|
+
|
|
655
|
+
if session_token:
|
|
656
|
+
return self.claims_from_session(repo, session_token)
|
|
657
|
+
|
|
658
|
+
raise self.not_authenticated_exception()
|
|
659
|
+
|
|
660
|
+
def claims_from_session(
|
|
661
|
+
self,
|
|
662
|
+
repo: OIDCSessionRepository,
|
|
663
|
+
session_id: str,
|
|
664
|
+
) -> dict[str, Any]:
|
|
665
|
+
"""Load a server-side OIDC session, refreshing token material when needed."""
|
|
666
|
+
try:
|
|
667
|
+
return self.sessions.claims_from_session(
|
|
668
|
+
repo,
|
|
669
|
+
session_id,
|
|
670
|
+
authorize_claims=self.ensure_authorized,
|
|
671
|
+
)
|
|
672
|
+
except OIDCAuthenticationError as exc:
|
|
673
|
+
raise self.not_authenticated_exception(exc.detail) from exc
|
|
674
|
+
|
|
675
|
+
def refresh_session(
|
|
676
|
+
self,
|
|
677
|
+
repo: OIDCSessionRepository,
|
|
678
|
+
session: Any,
|
|
679
|
+
) -> dict[str, Any]:
|
|
680
|
+
"""Refresh an OIDC session using its stored refresh token."""
|
|
681
|
+
try:
|
|
682
|
+
return self.sessions.refresh_session(
|
|
683
|
+
repo,
|
|
684
|
+
session,
|
|
685
|
+
authorize_claims=self.ensure_authorized,
|
|
686
|
+
)
|
|
687
|
+
except OIDCAuthenticationError as exc:
|
|
688
|
+
raise self.not_authenticated_exception(exc.detail) from exc
|
|
689
|
+
|
|
690
|
+
def not_authenticated_exception(
|
|
691
|
+
self,
|
|
692
|
+
detail: str = "Not authenticated.",
|
|
693
|
+
) -> HTTPException:
|
|
694
|
+
"""Return the standard unauthenticated exception for OIDC session flows."""
|
|
695
|
+
return HTTPException(
|
|
696
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
697
|
+
detail=detail,
|
|
698
|
+
headers={"X-Auth-Login-Url": self.config.login_path},
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _role_value(role: Any) -> str:
|
|
703
|
+
return str(getattr(role, "value", role))
|