tba-app-backend-public 0.4.0.dev2__tar.gz

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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: tba-app-backend-public
3
+ Version: 0.4.0.dev2
4
+ Summary: Public OAuth/OIDC and human-login backend application for TBA.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10,<3.15
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: tba-app-backend-core (==0.4.0.dev2)
14
+ Requires-Dist: tba-auth (==0.4.0.dev2)
15
+ Requires-Dist: tba-router-oauth-authorization (==0.4.0.dev2)
16
+ Requires-Dist: tba-router-oauth-authorization-server-metadata (==0.4.0.dev2)
17
+ Requires-Dist: tba-router-oauth-device-authorization (==0.4.0.dev2)
18
+ Requires-Dist: tba-router-oauth-introspection (==0.4.0.dev2)
19
+ Requires-Dist: tba-router-oauth-protected-resource-metadata (==0.4.0.dev2)
20
+ Requires-Dist: tba-router-oauth-pushed-authorization-request (==0.4.0.dev2)
21
+ Requires-Dist: tba-router-oauth-registration (==0.4.0.dev2)
22
+ Requires-Dist: tba-router-oauth-revocation (==0.4.0.dev2)
23
+ Requires-Dist: tba-router-oauth-token (==0.4.0.dev2)
24
+ Requires-Dist: tba-router-oauth-token-exchange (==0.4.0.dev2)
25
+ Requires-Dist: tba-router-oidc-discovery (==0.4.0.dev2)
26
+ Requires-Dist: tba-router-oidc-logout (==0.4.0.dev2)
27
+ Requires-Dist: tba-router-oidc-userinfo (==0.4.0.dev2)
28
+ Requires-Dist: tba-router-session-login (==0.4.0.dev2)
29
+ Requires-Dist: tba-router-webauthn (==0.4.0.dev2)
30
+ Project-URL: Homepage, https://github.com/tigrbl/tigrbl_auth
31
+ Project-URL: Repository, https://github.com/tigrbl/tigrbl_auth
32
+ Project-URL: Source, https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-public
33
+ Description-Content-Type: text/markdown
34
+
35
+ # tba-app-backend-public
36
+
37
+ Public OAuth/OIDC and white-label login API front door.
38
+
39
+ ## Surface
40
+
41
+ | Property | Value |
42
+ | --- | --- |
43
+ | Product surface | `public-app` |
44
+ | Intended UIX | `@tba/ui-public` |
45
+ | Exposes | Login, authorize, token, logout, registration, discovery, JWKS, and public OAuth/OIDC helpers |
46
+ | Excludes | `/admin/*`, `/rpc`, diagnostics, tenant lifecycle administration |
47
+
48
+ ## Entrypoint
49
+
50
+ ```python
51
+ from tba_app_backend_public import app, build_app
52
+ ```
53
+
54
+ ## Docker dev deployment
55
+
56
+ From the repository root:
57
+
58
+ ```bash
59
+ docker compose -f docker/docker-compose.public-app.yml up -d --build
60
+ ```
61
+
62
+ The dev deployment publishes the public API on `http://localhost:8013`.
63
+ It runs the `production` profile with TLS disabled for local development so
64
+ registration, login, authorization, token, discovery, and JWKS routes are
65
+ available on the same localhost issuer.
66
+
67
+ Useful checks:
68
+
69
+ ```bash
70
+ curl http://localhost:8013/.well-known/openid-configuration
71
+ curl http://localhost:8013/.well-known/jwks.json
72
+ curl -i http://localhost:8013/tenant
73
+ curl -i http://localhost:8013/diagnostics
74
+ ```
75
+
@@ -0,0 +1,40 @@
1
+ # tba-app-backend-public
2
+
3
+ Public OAuth/OIDC and white-label login API front door.
4
+
5
+ ## Surface
6
+
7
+ | Property | Value |
8
+ | --- | --- |
9
+ | Product surface | `public-app` |
10
+ | Intended UIX | `@tba/ui-public` |
11
+ | Exposes | Login, authorize, token, logout, registration, discovery, JWKS, and public OAuth/OIDC helpers |
12
+ | Excludes | `/admin/*`, `/rpc`, diagnostics, tenant lifecycle administration |
13
+
14
+ ## Entrypoint
15
+
16
+ ```python
17
+ from tba_app_backend_public import app, build_app
18
+ ```
19
+
20
+ ## Docker dev deployment
21
+
22
+ From the repository root:
23
+
24
+ ```bash
25
+ docker compose -f docker/docker-compose.public-app.yml up -d --build
26
+ ```
27
+
28
+ The dev deployment publishes the public API on `http://localhost:8013`.
29
+ It runs the `production` profile with TLS disabled for local development so
30
+ registration, login, authorization, token, discovery, and JWKS routes are
31
+ available on the same localhost issuer.
32
+
33
+ Useful checks:
34
+
35
+ ```bash
36
+ curl http://localhost:8013/.well-known/openid-configuration
37
+ curl http://localhost:8013/.well-known/jwks.json
38
+ curl -i http://localhost:8013/tenant
39
+ curl -i http://localhost:8013/diagnostics
40
+ ```
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "tba-app-backend-public"
3
+ version = "0.4.0.dev2"
4
+ description = "Public OAuth/OIDC and human-login backend application for TBA."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "Apache-2.0"
8
+ dependencies = [
9
+ "tba-auth==0.4.0.dev2",
10
+ "tba-app-backend-core==0.4.0.dev2",
11
+ "tba-router-oauth-authorization==0.4.0.dev2",
12
+ "tba-router-oauth-authorization-server-metadata==0.4.0.dev2",
13
+ "tba-router-oauth-device-authorization==0.4.0.dev2",
14
+ "tba-router-oauth-introspection==0.4.0.dev2",
15
+ "tba-router-oauth-pushed-authorization-request==0.4.0.dev2",
16
+ "tba-router-oauth-protected-resource-metadata==0.4.0.dev2",
17
+ "tba-router-oauth-registration==0.4.0.dev2",
18
+ "tba-router-oauth-revocation==0.4.0.dev2",
19
+ "tba-router-oauth-token==0.4.0.dev2",
20
+ "tba-router-oauth-token-exchange==0.4.0.dev2",
21
+ "tba-router-oidc-discovery==0.4.0.dev2",
22
+ "tba-router-oidc-logout==0.4.0.dev2",
23
+ "tba-router-oidc-userinfo==0.4.0.dev2",
24
+ "tba-router-session-login==0.4.0.dev2",
25
+ "tba-router-webauthn==0.4.0.dev2",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/tigrbl/tigrbl_auth"
30
+ Repository = "https://github.com/tigrbl/tigrbl_auth"
31
+ Source = "https://github.com/tigrbl/tigrbl_auth/tree/master/pkgs/90-backend-apps/tba-app-backend-public"
32
+
33
+ [build-system]
34
+ requires = ["poetry-core>=1.0.0"]
35
+ build-backend = "poetry.core.masonry.api"
36
+
37
+ [tool.poetry]
38
+ packages = [{ include = "tba_app_backend_public", from = "src" }]
@@ -0,0 +1,14 @@
1
+ """Public backend application package for TBA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import PRODUCT_SURFACE, app, build_app
6
+ from .contract import PUBLIC_BACKEND_APP_CONTRACT, PublicBackendAppContract
7
+
8
+ __all__ = [
9
+ "PRODUCT_SURFACE",
10
+ "PUBLIC_BACKEND_APP_CONTRACT",
11
+ "PublicBackendAppContract",
12
+ "app",
13
+ "build_app",
14
+ ]
@@ -0,0 +1,64 @@
1
+ """ASGI entrypoint for the public TBA backend application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING
7
+
8
+ from tba_app_backend_core import build_product_app
9
+ from tba_app_backend_public.contract import PUBLIC_BACKEND_APP_CONTRACT
10
+ from tba_auth.runtime import LazyASGIApplication
11
+ from tba_app_backend_public.openapi import patch_public_openapi
12
+
13
+ if TYPE_CHECKING:
14
+ from tigrbl import TigrblApp
15
+
16
+ PRODUCT_SURFACE = "public-app"
17
+
18
+
19
+ def _default_settings() -> object:
20
+ from tba_auth.config.settings import Settings
21
+
22
+ settings = Settings()
23
+ env_overrides = {
24
+ "issuer": os.environ.get("AUTHN_ISSUER"),
25
+ "protected_resource_identifier": os.environ.get(
26
+ "TBA_AUTH_PROTECTED_RESOURCE_IDENTIFIER"
27
+ ),
28
+ "deployment_profile": os.environ.get("TBA_AUTH_PROFILE"),
29
+ "require_tls": os.environ.get("TBA_AUTH_REQUIRE_TLS"),
30
+ "session_cookie_force_secure": os.environ.get(
31
+ "TBA_AUTH_SESSION_COOKIE_FORCE_SECURE"
32
+ ),
33
+ "enable_rfc9207": os.environ.get("TBA_AUTH_ENABLE_RFC9207"),
34
+ }
35
+ for name, value in env_overrides.items():
36
+ if value in {None, ""}:
37
+ continue
38
+ current = getattr(settings, name, None)
39
+ if isinstance(current, bool):
40
+ value = str(value).lower() in {"1", "true", "yes", "on"}
41
+ setattr(settings, name, value)
42
+ return settings
43
+
44
+
45
+ def _package_default_profile(settings_obj: object | None = None) -> str:
46
+ resolved = settings_obj if settings_obj is not None else _default_settings()
47
+ configured = str(getattr(resolved, "deployment_profile", "baseline") or "baseline")
48
+ return configured if configured != "baseline" else "production"
49
+
50
+
51
+ def build_app(settings_obj: object | None = None) -> "TigrblApp":
52
+ if settings_obj is None:
53
+ settings_obj = _default_settings()
54
+ settings_obj.deployment_profile = _package_default_profile(settings_obj)
55
+ return patch_public_openapi(
56
+ build_product_app(
57
+ PRODUCT_SURFACE, settings_obj, contract=PUBLIC_BACKEND_APP_CONTRACT
58
+ )
59
+ )
60
+
61
+
62
+ app = LazyASGIApplication(build_app)
63
+
64
+ __all__ = ["PRODUCT_SURFACE", "_package_default_profile", "app", "build_app"]
@@ -0,0 +1,110 @@
1
+ """Public backend-app contract for TBA."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class PublicBackendAppContract:
10
+ """Machine-readable boundary for the public OAuth/OIDC backend app."""
11
+
12
+ product_surface: str
13
+ intended_uix: str
14
+ baseline_capabilities: tuple[str, ...]
15
+ production_capabilities: tuple[str, ...]
16
+ forbidden_route_prefixes: tuple[str, ...]
17
+ forbidden_exact_routes: tuple[str, ...]
18
+ consumed_packages: tuple[str, ...]
19
+ mounted_router_packages: tuple[str, ...]
20
+ app_local_route_groups: tuple[str, ...]
21
+
22
+
23
+ PUBLIC_BACKEND_APP_CONTRACT = PublicBackendAppContract(
24
+ product_surface="public-app",
25
+ intended_uix="@tba/ui-public",
26
+ baseline_capabilities=(
27
+ "login",
28
+ "authorize",
29
+ "token",
30
+ "rest-only",
31
+ "openid-configuration",
32
+ "tenant-openid-configuration",
33
+ "oauth-authorization-server-metadata",
34
+ "jwks",
35
+ "tenant-jwks",
36
+ "standards-manifest",
37
+ "oid4vci-credential",
38
+ "oid4vp-verification",
39
+ "authzen-evaluation",
40
+ "gnap-transaction",
41
+ "set-receiver",
42
+ "attestation-appraisal",
43
+ ),
44
+ production_capabilities=(
45
+ "login",
46
+ "authorize",
47
+ "token",
48
+ "userinfo",
49
+ "introspection",
50
+ "register",
51
+ "register-management",
52
+ "revoke",
53
+ "logout",
54
+ "openid-configuration",
55
+ "tenant-openid-configuration",
56
+ "oauth-authorization-server-metadata",
57
+ "oauth-protected-resource-metadata",
58
+ "jwks",
59
+ "tenant-jwks",
60
+ "standards-manifest",
61
+ "oid4vci-credential",
62
+ "oid4vp-verification",
63
+ "authzen-evaluation",
64
+ "gnap-transaction",
65
+ "set-receiver",
66
+ "attestation-appraisal",
67
+ ),
68
+ forbidden_route_prefixes=(
69
+ "/admin",
70
+ "/cryptokey",
71
+ "/cryptokeyversion",
72
+ "/principalkeybinding",
73
+ "/keyenvelope",
74
+ "/keyattestationevidence",
75
+ "/tenant/",
76
+ "/diagnostics",
77
+ ),
78
+ forbidden_exact_routes=("/tenant",),
79
+ consumed_packages=(
80
+ "tba-auth",
81
+ "tba-server-identity",
82
+ "tba-runtime-identity",
83
+ "tba-protocol-oauth",
84
+ "tba-protocol-oidc",
85
+ "tba-provider-identity-jose",
86
+ "tba-protocol-oid4vci",
87
+ "tba-protocol-oid4vp",
88
+ "tba-profile-haip",
89
+ ),
90
+ mounted_router_packages=(
91
+ "tba-router-oauth-authorization",
92
+ "tba-router-oauth-authorization-server-metadata",
93
+ "tba-router-oauth-device-authorization",
94
+ "tba-router-oauth-introspection",
95
+ "tba-router-oauth-pushed-authorization-request",
96
+ "tba-router-oauth-protected-resource-metadata",
97
+ "tba-router-oauth-registration",
98
+ "tba-router-oauth-revocation",
99
+ "tba-router-oauth-token",
100
+ "tba-router-oauth-token-exchange",
101
+ "tba-router-oidc-discovery",
102
+ "tba-router-oidc-logout",
103
+ "tba-router-oidc-userinfo",
104
+ "tba-router-session-login",
105
+ "tba-router-webauthn",
106
+ ),
107
+ app_local_route_groups=(),
108
+ )
109
+
110
+ __all__ = ["PUBLIC_BACKEND_APP_CONTRACT", "PublicBackendAppContract"]
@@ -0,0 +1,350 @@
1
+ """OpenAPI affordances for the public backend app.
2
+
3
+ The shared runtime intentionally hand-parses several OAuth/OIDC endpoints so it
4
+ can accept exact protocol wire formats. Tigrbl cannot infer request bodies from
5
+ those handlers, so this package overlays the public front-door documentation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from copy import deepcopy
11
+ from dataclasses import replace
12
+ from typing import Any, Callable
13
+
14
+
15
+ _LOGIN_BODY = {
16
+ "required": True,
17
+ "content": {
18
+ "application/json": {
19
+ "schema": {"$ref": "#/components/schemas/CredsIn"},
20
+ "example": {"identifier": "admin", "password": "AdminPass123!"},
21
+ }
22
+ },
23
+ }
24
+
25
+ _REGISTER_BODY = {
26
+ "required": True,
27
+ "content": {
28
+ "application/json": {
29
+ "schema": {"$ref": "#/components/schemas/DynamicClientRegistrationIn"},
30
+ "example": {
31
+ "tenant_slug": "public",
32
+ "redirect_uris": ["http://127.0.0.1:8080/callback"],
33
+ "grant_types": ["authorization_code", "refresh_token", "password"],
34
+ "response_types": ["code"],
35
+ "token_endpoint_auth_method": "client_secret_post",
36
+ "client_name": "Local dev client",
37
+ "scope": "openid profile email",
38
+ },
39
+ }
40
+ },
41
+ }
42
+
43
+ _TOKEN_BODY = {
44
+ "required": True,
45
+ "content": {
46
+ "application/x-www-form-urlencoded": {
47
+ "schema": {
48
+ "type": "object",
49
+ "required": ["grant_type", "client_id"],
50
+ "properties": {
51
+ "grant_type": {
52
+ "type": "string",
53
+ "enum": [
54
+ "authorization_code",
55
+ "client_credentials",
56
+ "password",
57
+ "refresh_token",
58
+ ],
59
+ },
60
+ "client_id": {"type": "string"},
61
+ "client_secret": {"type": "string"},
62
+ "username": {"type": "string"},
63
+ "password": {"type": "string", "format": "password"},
64
+ "scope": {"type": "string"},
65
+ "code": {"type": "string"},
66
+ "redirect_uri": {"type": "string", "format": "uri"},
67
+ "code_verifier": {"type": "string"},
68
+ "refresh_token": {"type": "string"},
69
+ "resource": {"type": "string"},
70
+ },
71
+ },
72
+ "examples": {
73
+ "password": {
74
+ "summary": "Password grant after dynamic client registration",
75
+ "value": {
76
+ "grant_type": "password",
77
+ "client_id": "<registered-client-id>",
78
+ "client_secret": "<registered-client-secret>",
79
+ "username": "admin",
80
+ "password": "AdminPass123!",
81
+ "scope": "openid profile email",
82
+ },
83
+ },
84
+ "authorization_code": {
85
+ "summary": "Authorization code exchange",
86
+ "value": {
87
+ "grant_type": "authorization_code",
88
+ "client_id": "<registered-client-id>",
89
+ "client_secret": "<registered-client-secret>",
90
+ "code": "<authorization-code>",
91
+ "redirect_uri": "http://127.0.0.1:8080/callback",
92
+ "code_verifier": "<pkce-code-verifier>",
93
+ },
94
+ },
95
+ },
96
+ }
97
+ },
98
+ }
99
+
100
+ _LOGOUT_BODY = {
101
+ "required": False,
102
+ "content": {
103
+ "application/json": {
104
+ "schema": {"$ref": "#/components/schemas/LogoutIn"},
105
+ "example": {"client_id": "<registered-client-id>", "state": "dev-state"},
106
+ }
107
+ },
108
+ }
109
+
110
+ _TOKEN_REFERENCE_BODY = {
111
+ "required": True,
112
+ "content": {
113
+ "application/x-www-form-urlencoded": {
114
+ "schema": {
115
+ "type": "object",
116
+ "required": ["token"],
117
+ "properties": {
118
+ "token": {"type": "string"},
119
+ "token_type_hint": {"type": "string"},
120
+ "client_id": {"type": "string"},
121
+ "client_secret": {"type": "string"},
122
+ },
123
+ }
124
+ }
125
+ },
126
+ }
127
+
128
+ _AUTHORIZE_PARAMS = [
129
+ {
130
+ "name": "response_type",
131
+ "in": "query",
132
+ "required": True,
133
+ "schema": {"type": "string", "default": "code"},
134
+ },
135
+ {
136
+ "name": "client_id",
137
+ "in": "query",
138
+ "required": True,
139
+ "schema": {"type": "string"},
140
+ },
141
+ {
142
+ "name": "redirect_uri",
143
+ "in": "query",
144
+ "required": True,
145
+ "schema": {
146
+ "type": "string",
147
+ "format": "uri",
148
+ "default": "http://127.0.0.1:8080/callback",
149
+ },
150
+ },
151
+ {
152
+ "name": "scope",
153
+ "in": "query",
154
+ "required": False,
155
+ "schema": {"type": "string", "default": "openid profile email"},
156
+ },
157
+ {
158
+ "name": "state",
159
+ "in": "query",
160
+ "required": False,
161
+ "schema": {"type": "string", "default": "dev-state"},
162
+ },
163
+ {
164
+ "name": "nonce",
165
+ "in": "query",
166
+ "required": False,
167
+ "schema": {"type": "string", "default": "dev-nonce"},
168
+ },
169
+ {
170
+ "name": "code_challenge",
171
+ "in": "query",
172
+ "required": True,
173
+ "schema": {
174
+ "type": "string",
175
+ "default": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
176
+ },
177
+ },
178
+ {
179
+ "name": "code_challenge_method",
180
+ "in": "query",
181
+ "required": True,
182
+ "schema": {"type": "string", "enum": ["S256"], "default": "S256"},
183
+ },
184
+ ]
185
+
186
+
187
+ def _ensure_schema(openapi: dict[str, Any], name: str, schema: dict[str, Any]) -> None:
188
+ components = openapi.setdefault("components", {})
189
+ schemas = components.setdefault("schemas", {})
190
+ schemas.setdefault(name, schema)
191
+
192
+
193
+ def _operation(openapi: dict[str, Any], path: str, method: str) -> dict[str, Any]:
194
+ return openapi.setdefault("paths", {}).setdefault(path, {}).setdefault(method, {})
195
+
196
+
197
+ def _replace_params(operation: dict[str, Any], params: list[dict[str, Any]]) -> None:
198
+ existing = operation.get("parameters") or []
199
+ preserved = [
200
+ param
201
+ for param in existing
202
+ if (param.get("in"), param.get("name"))
203
+ not in {(item["in"], item["name"]) for item in params}
204
+ ]
205
+ operation["parameters"] = preserved + deepcopy(params)
206
+
207
+
208
+ def patch_public_openapi(app: object) -> object:
209
+ """Patch generated OpenAPI for public protocol endpoints."""
210
+ target = getattr(app, "app", app)
211
+ original_openapi: Callable[[], dict[str, Any]] = target.openapi
212
+
213
+ def public_openapi() -> dict[str, Any]:
214
+ cached = getattr(target, "openapi_schema", None)
215
+ if cached:
216
+ return cached
217
+
218
+ openapi = original_openapi()
219
+ _ensure_schema(
220
+ openapi,
221
+ "CredsIn",
222
+ {
223
+ "type": "object",
224
+ "required": ["identifier", "password"],
225
+ "properties": {
226
+ "identifier": {"type": "string", "minLength": 3},
227
+ "password": {
228
+ "type": "string",
229
+ "format": "password",
230
+ "minLength": 8,
231
+ },
232
+ },
233
+ },
234
+ )
235
+ _ensure_schema(
236
+ openapi,
237
+ "DynamicClientRegistrationIn",
238
+ {
239
+ "type": "object",
240
+ "required": ["redirect_uris"],
241
+ "properties": {
242
+ "tenant_slug": {"type": "string", "default": "public"},
243
+ "redirect_uris": {
244
+ "type": "array",
245
+ "items": {"type": "string", "format": "uri"},
246
+ },
247
+ "grant_types": {
248
+ "type": "array",
249
+ "items": {"type": "string"},
250
+ "default": ["authorization_code"],
251
+ },
252
+ "response_types": {
253
+ "type": "array",
254
+ "items": {"type": "string"},
255
+ "default": ["code"],
256
+ },
257
+ "token_endpoint_auth_method": {
258
+ "type": "string",
259
+ "default": "client_secret_basic",
260
+ },
261
+ "scope": {"type": "string"},
262
+ "client_name": {"type": "string"},
263
+ },
264
+ },
265
+ )
266
+ _ensure_schema(
267
+ openapi,
268
+ "LogoutIn",
269
+ {
270
+ "type": "object",
271
+ "properties": {
272
+ "id_token_hint": {"type": "string"},
273
+ "post_logout_redirect_uri": {"type": "string", "format": "uri"},
274
+ "state": {"type": "string"},
275
+ "sid": {"type": "string"},
276
+ "client_id": {"type": "string"},
277
+ },
278
+ },
279
+ )
280
+
281
+ _operation(openapi, "/login", "post")["requestBody"] = deepcopy(_LOGIN_BODY)
282
+ _operation(openapi, "/register", "post")["requestBody"] = deepcopy(
283
+ _REGISTER_BODY
284
+ )
285
+ _operation(openapi, "/token", "post")["requestBody"] = deepcopy(_TOKEN_BODY)
286
+ _operation(openapi, "/logout", "post")["requestBody"] = deepcopy(_LOGOUT_BODY)
287
+ _operation(openapi, "/revoke", "post")["requestBody"] = deepcopy(
288
+ _TOKEN_REFERENCE_BODY
289
+ )
290
+ if "/introspect" in openapi.get("paths", {}):
291
+ _operation(openapi, "/introspect", "post")["requestBody"] = deepcopy(
292
+ _TOKEN_REFERENCE_BODY
293
+ )
294
+ if "/authorize" in openapi.get("paths", {}):
295
+ _replace_params(_operation(openapi, "/authorize", "get"), _AUTHORIZE_PARAMS)
296
+
297
+ setattr(target, "openapi_schema", openapi)
298
+ return openapi
299
+
300
+ target.openapi = public_openapi
301
+ if target is not app:
302
+ app.openapi = public_openapi
303
+
304
+ routes = getattr(target, "_routes", None)
305
+ if isinstance(routes, list):
306
+ from tigrbl_concrete._concrete._response import Response
307
+
308
+ def _openapi_handler(request: Any) -> Response:
309
+ return Response.json(public_openapi())
310
+
311
+ patched_routes = []
312
+ for route in routes:
313
+ path = str(
314
+ getattr(route, "path_template", None) or getattr(route, "path", "")
315
+ )
316
+ methods = set(getattr(route, "methods", ()) or ())
317
+ if path == "/openapi.json" and "GET" in methods:
318
+ patched_routes.append(replace(route, handler=_openapi_handler))
319
+ else:
320
+ patched_routes.append(route)
321
+ routes[:] = patched_routes
322
+ return PublicOpenAPIGate(app)
323
+
324
+
325
+ __all__ = ["patch_public_openapi"]
326
+
327
+
328
+ class PublicOpenAPIGate:
329
+ """ASGI wrapper that serves the patched public OpenAPI document."""
330
+
331
+ def __init__(self, app: object) -> None:
332
+ self.app = app
333
+
334
+ def __getattr__(self, name: str) -> Any:
335
+ return getattr(self.app, name)
336
+
337
+ def openapi(self) -> dict[str, Any]:
338
+ return self.app.openapi()
339
+
340
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
341
+ if (
342
+ scope.get("type") == "http"
343
+ and str(scope.get("method", "")).upper() == "GET"
344
+ and scope.get("path") == "/openapi.json"
345
+ ):
346
+ from tigrbl_concrete._concrete._response import Response
347
+
348
+ await Response.json(self.openapi())(scope, receive, send)
349
+ return
350
+ await self.app(scope, receive, send)