tba-app-backend-core 0.4.0.dev2__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 (30) hide show
  1. tba_app_backend_core/__init__.py +15 -0
  2. tba_app_backend_core/app.py +146 -0
  3. tba_app_backend_core/plugin.py +36 -0
  4. tba_app_backend_core/product.py +55 -0
  5. tba_app_backend_core/py.typed +0 -0
  6. tba_app_backend_core/surfaces/__init__.py +3 -0
  7. tba_app_backend_core/surfaces/admin_routes.py +353 -0
  8. tba_app_backend_core/surfaces/advanced_protocols.py +170 -0
  9. tba_app_backend_core/surfaces/auth_flows.py +16 -0
  10. tba_app_backend_core/surfaces/authorization_server_metadata_surface.py +37 -0
  11. tba_app_backend_core/surfaces/authorization_surface.py +15 -0
  12. tba_app_backend_core/surfaces/authz_surface.py +14 -0
  13. tba_app_backend_core/surfaces/client_registration_surface.py +366 -0
  14. tba_app_backend_core/surfaces/device_authorization_surface.py +44 -0
  15. tba_app_backend_core/surfaces/introspection_surface.py +247 -0
  16. tba_app_backend_core/surfaces/login_surface.py +25 -0
  17. tba_app_backend_core/surfaces/logout_surface.py +38 -0
  18. tba_app_backend_core/surfaces/oidc_discovery_surface.py +79 -0
  19. tba_app_backend_core/surfaces/par_surface.py +394 -0
  20. tba_app_backend_core/surfaces/protected_resource_metadata_surface.py +34 -0
  21. tba_app_backend_core/surfaces/resource_validation_metadata_surface.py +33 -0
  22. tba_app_backend_core/surfaces/revocation_surface.py +39 -0
  23. tba_app_backend_core/surfaces/standards_manifest.py +24 -0
  24. tba_app_backend_core/surfaces/surface_api.py +284 -0
  25. tba_app_backend_core/surfaces/token_exchange_surface.py +34 -0
  26. tba_app_backend_core/surfaces/token_surface.py +12 -0
  27. tba_app_backend_core/surfaces/userinfo_surface.py +34 -0
  28. tba_app_backend_core-0.4.0.dev2.dist-info/METADATA +60 -0
  29. tba_app_backend_core-0.4.0.dev2.dist-info/RECORD +30 -0
  30. tba_app_backend_core-0.4.0.dev2.dist-info/WHEEL +4 -0
@@ -0,0 +1,394 @@
1
+ """Runtime collaborators and composition for the RFC 9126 HTTP carrier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from uuid import UUID
9
+
10
+ from tigrbl import Request, TigrblApp
11
+ from tigrbl.runtime.status import HTTPException, status
12
+ from tba_router_oauth_pushed_authorization_request import (
13
+ build_pushed_authorization_router,
14
+ include_pushed_authorization_endpoint as include_http_par_endpoint,
15
+ )
16
+ from tba_protocol_oauth.standards.dpop import verify_proof_async
17
+ from tba_protocol_oauth.standards.jwt_client_auth import (
18
+ PRIVATE_KEY_JWT_AUTH_METHOD,
19
+ authenticate_client_assertion,
20
+ )
21
+ from tba_protocol_oauth.standards.jwt_secured_authorization_requests import (
22
+ merge_request_object_params,
23
+ parse_request_object,
24
+ )
25
+ from tba_protocol_oauth.standards.mutual_tls_client_authentication import (
26
+ SUPPORTED_MTLS_AUTH_METHODS,
27
+ authenticate_mtls_client,
28
+ presented_certificate_pem,
29
+ )
30
+ from tba_protocol_oauth.standards.oauth_security_bcp import (
31
+ client_certificate_thumbprint_from_request,
32
+ dpop_proof_from_request,
33
+ runtime_security_profile,
34
+ )
35
+ from tba_protocol_oauth.standards.resource_indicators import (
36
+ select_resource_indicator,
37
+ )
38
+ from tba_protocol_oauth.standards.rich_authorization_requests import (
39
+ normalize_authorization_details,
40
+ )
41
+ from tba_contracts_identity.oauth import PushedAuthorizationPersistenceRequest
42
+ from tba_runtime_identity.deployment import (
43
+ deployment_from_app,
44
+ deployment_from_request,
45
+ resolve_deployment,
46
+ )
47
+ from tba_runtime_identity.settings import settings
48
+ from tba_storage_oauth.tables import Client, ClientRegistration
49
+ from tba_storage_oauth.tables.pushed_authorization_request import (
50
+ DEFAULT_PAR_EXPIRY,
51
+ )
52
+ from tba_runtime_identity_storage.dpop_state import (
53
+ check_and_store_dpop_replay,
54
+ consume_dpop_nonce,
55
+ )
56
+ from tba_runtime_identity_storage.engine import get_db
57
+ from tigrbl import first_record, read_record
58
+
59
+ from tba_server_identity.security.pushed_authorization import (
60
+ build_rfc9126_pushed_authorization_service,
61
+ )
62
+
63
+
64
+ _CLIENT_AUTH_PARAMETERS = frozenset(
65
+ {"client_assertion", "client_assertion_type", "client_secret"}
66
+ )
67
+
68
+
69
+ def _header(request: Request, name: str) -> str | None:
70
+ headers = getattr(request, "headers", {}) or {}
71
+ return headers.get(name) or headers.get(name.lower())
72
+
73
+
74
+ def _resolve_request_deployment(request: Request):
75
+ if getattr(request, "app", None) is not None:
76
+ return deployment_from_request(request, settings)
77
+ return resolve_deployment(settings)
78
+
79
+
80
+ async def _normalized_par_params(
81
+ params: Mapping[str, object],
82
+ deployment,
83
+ ) -> dict[str, object]:
84
+ normalized = dict(params)
85
+ request_object = normalized.get("request")
86
+ if request_object:
87
+ if not deployment.flag_enabled("enable_rfc9101"):
88
+ raise HTTPException(
89
+ status.HTTP_400_BAD_REQUEST,
90
+ "request object support disabled",
91
+ )
92
+ try:
93
+ parsed_request = await parse_request_object(
94
+ str(request_object),
95
+ secret=settings.jwt_secret,
96
+ algorithms=("HS256", "HS384", "HS512"),
97
+ expected_client_id=str(normalized.get("client_id") or "") or None,
98
+ expected_audience=str(deployment.issuer or settings.issuer),
99
+ )
100
+ normalized = merge_request_object_params(
101
+ parsed_request,
102
+ normalized,
103
+ allow_query_overrides=("request",),
104
+ )
105
+ except Exception as exc:
106
+ raise HTTPException(
107
+ status.HTTP_400_BAD_REQUEST,
108
+ "invalid request object",
109
+ ) from exc
110
+
111
+ resources = normalized.get("resource")
112
+ if resources not in (None, "", [], (), {}):
113
+ values = resources if isinstance(resources, list) else [resources]
114
+ if not deployment.flag_enabled("enable_rfc8707"):
115
+ raise HTTPException(
116
+ status.HTTP_400_BAD_REQUEST,
117
+ "resource indicators disabled",
118
+ )
119
+ try:
120
+ selection = select_resource_indicator(
121
+ [str(item) for item in values],
122
+ audience=str(normalized.get("audience") or "") or None,
123
+ )
124
+ except ValueError as exc:
125
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid_target") from exc
126
+ normalized["resource"] = list(selection.resources)
127
+ if selection.audience:
128
+ normalized["audience"] = selection.audience
129
+
130
+ authorization_details = normalized.get("authorization_details")
131
+ if authorization_details not in (None, "", [], (), {}):
132
+ if not deployment.flag_enabled("enable_rfc9396"):
133
+ raise HTTPException(
134
+ status.HTTP_400_BAD_REQUEST,
135
+ "authorization_details disabled",
136
+ )
137
+ try:
138
+ binding = normalize_authorization_details(
139
+ authorization_details,
140
+ resource=str(normalized.get("audience") or "") or None,
141
+ audience=str(normalized.get("audience") or "") or None,
142
+ )
143
+ except Exception as exc:
144
+ raise HTTPException(
145
+ status.HTTP_400_BAD_REQUEST,
146
+ "invalid authorization_details",
147
+ ) from exc
148
+ normalized["authorization_details"] = binding.details
149
+ if binding.audience and normalized.get("audience") in {None, ""}:
150
+ normalized["audience"] = binding.audience
151
+ if binding.resource and normalized.get("resource") in (
152
+ None,
153
+ "",
154
+ [],
155
+ (),
156
+ {},
157
+ ):
158
+ normalized["resource"] = [binding.resource]
159
+ return normalized
160
+
161
+
162
+ async def normalize_pushed_authorization_request(
163
+ request: Request,
164
+ params: Mapping[str, object],
165
+ ) -> Mapping[str, object]:
166
+ return await _normalized_par_params(params, _resolve_request_deployment(request))
167
+
168
+
169
+ async def _authenticate_fapi_par_client(
170
+ *,
171
+ request: Request,
172
+ db,
173
+ params: Mapping[str, object],
174
+ deployment,
175
+ ):
176
+ client_id = str(params.get("client_id") or "").strip()
177
+ try:
178
+ client_uuid = UUID(client_id)
179
+ except (TypeError, ValueError, AttributeError) as exc:
180
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid client_id") from exc
181
+ client = await read_record(Client, db, client_uuid)
182
+ if client is None:
183
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "client not found")
184
+ registration = await first_record(
185
+ ClientRegistration,
186
+ db,
187
+ {"client_id": client.id},
188
+ )
189
+ metadata = dict(getattr(registration, "registration_metadata", None) or {})
190
+ auth_method = str(metadata.get("token_endpoint_auth_method") or "").strip()
191
+ policy = runtime_security_profile(deployment)
192
+ if auth_method not in set(policy.allowed_client_auth_methods):
193
+ raise HTTPException(
194
+ status.HTTP_400_BAD_REQUEST,
195
+ "invalid_client_metadata: FAPI clients must use private_key_jwt or mTLS",
196
+ )
197
+
198
+ authorization = _header(request, "Authorization")
199
+ client_assertion = str(params.get("client_assertion") or "").strip()
200
+ client_assertion_type = str(params.get("client_assertion_type") or "").strip()
201
+ if authorization and authorization.startswith("Basic "):
202
+ raise HTTPException(
203
+ status.HTTP_401_UNAUTHORIZED,
204
+ {
205
+ "error": "invalid_client",
206
+ "error_description": "FAPI PAR rejects HTTP Basic client authentication",
207
+ },
208
+ )
209
+ if auth_method == PRIVATE_KEY_JWT_AUTH_METHOD:
210
+ if not client_assertion:
211
+ raise HTTPException(
212
+ status.HTTP_401_UNAUTHORIZED,
213
+ {
214
+ "error": "invalid_client",
215
+ "error_description": "client_assertion required for FAPI PAR",
216
+ },
217
+ )
218
+ try:
219
+ authenticate_client_assertion(
220
+ client_assertion_type=client_assertion_type,
221
+ client_assertion=client_assertion,
222
+ audience=str(deployment.issuer or settings.issuer),
223
+ client_id=str(client.id),
224
+ token_endpoint_auth_method=auth_method,
225
+ )
226
+ except ValueError as exc:
227
+ raise HTTPException(
228
+ status.HTTP_401_UNAUTHORIZED,
229
+ {"error": "invalid_client", "error_description": str(exc)},
230
+ ) from exc
231
+ elif auth_method in SUPPORTED_MTLS_AUTH_METHODS:
232
+ try:
233
+ authenticate_mtls_client(
234
+ metadata,
235
+ client_certificate_thumbprint_from_request(request),
236
+ presented_certificate_pem=presented_certificate_pem(request),
237
+ token_endpoint_auth_method=auth_method,
238
+ )
239
+ except ValueError as exc:
240
+ raise HTTPException(
241
+ status.HTTP_401_UNAUTHORIZED,
242
+ {"error": "invalid_client", "error_description": str(exc)},
243
+ ) from exc
244
+ else:
245
+ raise HTTPException(
246
+ status.HTTP_401_UNAUTHORIZED,
247
+ {
248
+ "error": "invalid_client",
249
+ "error_description": "unsupported FAPI PAR authentication method",
250
+ },
251
+ )
252
+ return client
253
+
254
+
255
+ async def authorize_pushed_authorization_caller(
256
+ request: Request,
257
+ params: Mapping[str, object],
258
+ db,
259
+ ) -> PushedAuthorizationPersistenceRequest:
260
+ deployment = _resolve_request_deployment(request)
261
+ policy = runtime_security_profile(deployment)
262
+ client_id = str(params.get("client_id") or "").strip()
263
+ if not client_id:
264
+ raise HTTPException(
265
+ status.HTTP_400_BAD_REQUEST,
266
+ "client_id parameter required",
267
+ )
268
+ if policy.par_redirect_uri_required and not params.get("redirect_uri"):
269
+ raise HTTPException(
270
+ status.HTTP_400_BAD_REQUEST,
271
+ "redirect_uri parameter required",
272
+ )
273
+ try:
274
+ client_uuid = UUID(client_id)
275
+ except (TypeError, ValueError, AttributeError) as exc:
276
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid client_id") from exc
277
+ client = await read_record(Client, db, client_uuid)
278
+ if client is None:
279
+ raise HTTPException(status.HTTP_404_NOT_FOUND, "client not found")
280
+ if policy.par_client_auth_required:
281
+ client = await _authenticate_fapi_par_client(
282
+ request=request,
283
+ db=db,
284
+ params=params,
285
+ deployment=deployment,
286
+ )
287
+
288
+ persisted_params = {
289
+ key: value
290
+ for key, value in params.items()
291
+ if key not in _CLIENT_AUTH_PARAMETERS
292
+ }
293
+ dpop_proof = dpop_proof_from_request(request)
294
+ if dpop_proof:
295
+ try:
296
+ persisted_params["_dpop_jkt"] = await verify_proof_async(
297
+ dpop_proof,
298
+ getattr(request, "method", "POST"),
299
+ str(getattr(request, "url", "")),
300
+ replay_checker=lambda claims, ttl_s=300: check_and_store_dpop_replay(
301
+ db,
302
+ claims,
303
+ ttl_s=ttl_s,
304
+ ),
305
+ nonce_consumer=lambda nonce, now=None: consume_dpop_nonce(
306
+ db,
307
+ nonce,
308
+ now=now,
309
+ ),
310
+ )
311
+ except ValueError as exc:
312
+ raise HTTPException(
313
+ status.HTTP_400_BAD_REQUEST,
314
+ {
315
+ "error": "invalid_dpop_proof",
316
+ "error_description": str(exc),
317
+ },
318
+ ) from exc
319
+ cert_thumbprint = client_certificate_thumbprint_from_request(request)
320
+ if cert_thumbprint:
321
+ persisted_params["_mtls_thumbprint"] = cert_thumbprint
322
+
323
+ if DEFAULT_PAR_EXPIRY > policy.request_uri_max_lifetime_seconds:
324
+ raise HTTPException(
325
+ status.HTTP_400_BAD_REQUEST,
326
+ {
327
+ "error": "invalid_request",
328
+ "error_description": (
329
+ "request_uri lifetime exceeds the active profile limit"
330
+ ),
331
+ },
332
+ )
333
+ return PushedAuthorizationPersistenceRequest(
334
+ client_id=str(client.id),
335
+ tenant_id=(str(client.tenant_id) if client.tenant_id is not None else None),
336
+ params=persisted_params,
337
+ )
338
+
339
+
340
+ def _service_for_request(request: Request, db):
341
+ deployment = _resolve_request_deployment(request)
342
+ return build_rfc9126_pushed_authorization_service(
343
+ db,
344
+ SimpleNamespace(enable_rfc9126=deployment.flag_enabled("enable_rfc9126")),
345
+ )
346
+
347
+
348
+ def _repo_root() -> Path:
349
+ return Path(__file__).resolve().parents[5]
350
+
351
+
352
+ def observe_pushed_authorization_response(
353
+ request: Request,
354
+ payload: Mapping[str, object],
355
+ ) -> None:
356
+ from tba_runtime_identity_storage.session_service import observe_par_response
357
+
358
+ observe_par_response(
359
+ _repo_root(),
360
+ request_uri=payload.get("request_uri"),
361
+ details=dict(payload),
362
+ )
363
+
364
+
365
+ router = build_pushed_authorization_router(
366
+ service_for_request=_service_for_request,
367
+ normalize_request=normalize_pushed_authorization_request,
368
+ authorize_caller=authorize_pushed_authorization_caller,
369
+ observe_response=observe_pushed_authorization_response,
370
+ get_db=get_db,
371
+ )
372
+
373
+
374
+ def include_par_endpoint(app: TigrblApp) -> None:
375
+ deployment = deployment_from_app(app, settings)
376
+ include_http_par_endpoint(
377
+ app,
378
+ router,
379
+ enabled=deployment.flag_enabled("enable_rfc9126"),
380
+ )
381
+
382
+
383
+ include_rfc9126 = include_par_endpoint
384
+
385
+
386
+ __all__ = [
387
+ "_normalized_par_params",
388
+ "authorize_pushed_authorization_caller",
389
+ "include_par_endpoint",
390
+ "include_rfc9126",
391
+ "normalize_pushed_authorization_request",
392
+ "observe_pushed_authorization_response",
393
+ "router",
394
+ ]
@@ -0,0 +1,34 @@
1
+ """Runtime composition for the RFC 9728 HTTP carrier."""
2
+
3
+ from tigrbl import TigrblApp
4
+ from tba_router_oauth_protected_resource_metadata import (
5
+ build_protected_resource_metadata_router,
6
+ include_protected_resource_metadata,
7
+ )
8
+ from tba_protocol_oauth.standards.protected_resource_metadata import (
9
+ PROTECTED_RESOURCE_METADATA_PATH,
10
+ )
11
+ from tba_runtime_identity.deployment import deployment_from_app
12
+ from tba_runtime_identity.settings import settings
13
+
14
+ from tba_server_identity.protected_resource_metadata_runtime import (
15
+ protected_resource_metadata,
16
+ )
17
+
18
+
19
+ router = build_protected_resource_metadata_router(
20
+ protected_resource_metadata=protected_resource_metadata,
21
+ )
22
+ api = router
23
+
24
+
25
+ def include_rfc9728(app: TigrblApp) -> None:
26
+ deployment = deployment_from_app(app, settings)
27
+ include_protected_resource_metadata(
28
+ app,
29
+ router,
30
+ enabled=deployment.route_enabled(PROTECTED_RESOURCE_METADATA_PATH),
31
+ )
32
+
33
+
34
+ __all__ = ["api", "include_rfc9728", "router"]
@@ -0,0 +1,33 @@
1
+ """Runtime composition for resource-validation metadata carriers."""
2
+
3
+ from tigrbl import TigrblApp
4
+ from tba_router_resource_validation_metadata import (
5
+ build_resource_validation_metadata_router,
6
+ include_resource_validation_metadata as include_metadata_carrier,
7
+ )
8
+ from tba_runtime_identity.deployment import deployment_from_app
9
+ from tba_runtime_identity.settings import settings
10
+
11
+ from tba_server_identity.resource_validation_metadata_runtime import (
12
+ capability_metadata,
13
+ verifier_contract_metadata,
14
+ )
15
+
16
+
17
+ router = build_resource_validation_metadata_router(
18
+ capability_metadata=capability_metadata,
19
+ verifier_contract_metadata=verifier_contract_metadata,
20
+ )
21
+ api = router
22
+
23
+
24
+ def include_resource_validation_metadata(app: TigrblApp) -> None:
25
+ deployment = deployment_from_app(app, settings)
26
+ include_metadata_carrier(
27
+ app,
28
+ router,
29
+ enabled=deployment.product_surface == "resource-validation-app",
30
+ )
31
+
32
+
33
+ __all__ = ["api", "include_resource_validation_metadata", "router"]
@@ -0,0 +1,39 @@
1
+ """Runtime composition for the RFC 7009 HTTP carrier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tigrbl import Request, TigrblApp
6
+ from tba_router_oauth_revocation import (
7
+ build_revocation_router,
8
+ include_revocation_endpoint as include_http_revocation_endpoint,
9
+ )
10
+ from tba_runtime_identity.settings import settings
11
+
12
+ from tba_server_identity.security.token_revocation import (
13
+ build_rfc7009_revocation_service,
14
+ )
15
+
16
+
17
+ def _service_for_request(request: Request):
18
+ return build_rfc7009_revocation_service(settings)
19
+
20
+
21
+ router = build_revocation_router(service_for_request=_service_for_request)
22
+
23
+
24
+ def include_revocation_endpoint(app: TigrblApp) -> None:
25
+ include_http_revocation_endpoint(
26
+ app,
27
+ router,
28
+ enabled=bool(settings.enable_rfc7009),
29
+ )
30
+
31
+
32
+ include_rfc7009 = include_revocation_endpoint
33
+
34
+
35
+ __all__ = [
36
+ "include_revocation_endpoint",
37
+ "include_rfc7009",
38
+ "router",
39
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from tigrbl import TigrblApp, TigrblRouter
4
+ from tba_runtime_identity import standards_manifest
5
+
6
+ api = TigrblRouter()
7
+
8
+
9
+ @api.route("/standards", methods=["GET"], include_in_schema=True, tags=["standards"])
10
+ async def get_standards_manifest() -> dict[str, object]:
11
+ return {"standards": standards_manifest()}
12
+
13
+
14
+ def include_standards_manifest(app: TigrblApp) -> None:
15
+ path = "/standards"
16
+ routes = getattr(getattr(app, "router", app), "routes", ())
17
+ if not any(
18
+ (getattr(route, "path", None) or getattr(route, "path_template", None)) == path
19
+ for route in routes
20
+ ):
21
+ app.include_router(api)
22
+
23
+
24
+ __all__ = ["api", "get_standards_manifest", "include_standards_manifest"]