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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. 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))