jfastframework 0.1.0a1__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.
- jfastframework/__init__.py +44 -0
- jfastframework/app.py +155 -0
- jfastframework/auth/__init__.py +56 -0
- jfastframework/auth/jwks.py +117 -0
- jfastframework/auth/oidc.py +268 -0
- jfastframework/auth/principal.py +64 -0
- jfastframework/auth/store.py +141 -0
- jfastframework/auth/tokens.py +197 -0
- jfastframework/cli/__init__.py +5 -0
- jfastframework/cli/main.py +1549 -0
- jfastframework/cli/patcher.py +160 -0
- jfastframework/cli/scaffold.py +478 -0
- jfastframework/context.py +69 -0
- jfastframework/contracts/__init__.py +36 -0
- jfastframework/contracts/_scan.py +141 -0
- jfastframework/contracts/blocking.py +425 -0
- jfastframework/contracts/checker.py +318 -0
- jfastframework/contracts/model.py +306 -0
- jfastframework/contracts/render.py +116 -0
- jfastframework/db/__init__.py +13 -0
- jfastframework/db/base.py +47 -0
- jfastframework/db/repository.py +152 -0
- jfastframework/deploy/__init__.py +17 -0
- jfastframework/deploy/compose.py +182 -0
- jfastframework/deploy/kubernetes.py +431 -0
- jfastframework/deploy/serverless.py +199 -0
- jfastframework/deploy/workspace.py +256 -0
- jfastframework/errors.py +141 -0
- jfastframework/graph.py +109 -0
- jfastframework/health.py +110 -0
- jfastframework/languages.py +123 -0
- jfastframework/middleware.py +169 -0
- jfastframework/plugins/__init__.py +17 -0
- jfastframework/plugins/base.py +162 -0
- jfastframework/plugins/builtin/__init__.py +11 -0
- jfastframework/plugins/builtin/auth.py +698 -0
- jfastframework/plugins/builtin/cache.py +148 -0
- jfastframework/plugins/builtin/database.py +149 -0
- jfastframework/plugins/builtin/events.py +306 -0
- jfastframework/plugins/builtin/gateway.py +239 -0
- jfastframework/plugins/builtin/metrics.py +162 -0
- jfastframework/plugins/builtin/mongo.py +108 -0
- jfastframework/plugins/builtin/notifications.py +324 -0
- jfastframework/plugins/builtin/observability.py +186 -0
- jfastframework/plugins/builtin/qdrant.py +104 -0
- jfastframework/plugins/builtin/queue.py +262 -0
- jfastframework/plugins/builtin/rag.py +308 -0
- jfastframework/plugins/builtin/sentry.py +74 -0
- jfastframework/plugins/builtin/storage.py +293 -0
- jfastframework/plugins/builtin/tenancy.py +222 -0
- jfastframework/plugins/builtin/web.py +209 -0
- jfastframework/plugins/registry.py +182 -0
- jfastframework/py.typed +0 -0
- jfastframework/queues/__init__.py +10 -0
- jfastframework/queues/base.py +131 -0
- jfastframework/queues/postgres.py +211 -0
- jfastframework/queues/rabbitmq.py +139 -0
- jfastframework/queues/redis.py +235 -0
- jfastframework/queues/worker.py +204 -0
- jfastframework/resources.py +241 -0
- jfastframework/secrets.py +172 -0
- jfastframework/settings.py +173 -0
- jfastframework/sql.py +45 -0
- jfastframework/storage/__init__.py +38 -0
- jfastframework/storage/base.py +198 -0
- jfastframework/storage/local.py +192 -0
- jfastframework/storage/s3.py +295 -0
- jfastframework/templates/contracts_layered/contracts.toml.j2 +123 -0
- jfastframework/templates/contracts_screaming/contracts.toml.j2 +125 -0
- jfastframework/templates/frontend_react/.env.example.j2 +4 -0
- jfastframework/templates/frontend_react/.env.j2 +4 -0
- jfastframework/templates/frontend_react/.env.production.j2 +7 -0
- jfastframework/templates/frontend_react/.gitignore.j2 +6 -0
- jfastframework/templates/frontend_react/README.md.j2 +69 -0
- jfastframework/templates/frontend_react/index.html.j2 +13 -0
- jfastframework/templates/frontend_react/package.json.j2 +25 -0
- jfastframework/templates/frontend_react/src/components/BaseIcon.jsx.j2 +13 -0
- jfastframework/templates/frontend_react/src/components/CardBox.jsx.j2 +9 -0
- jfastframework/templates/frontend_react/src/components/SectionMain.jsx.j2 +3 -0
- jfastframework/templates/frontend_react/src/components/SectionTitleLineWithButton.jsx.j2 +18 -0
- jfastframework/templates/frontend_react/src/layouts/LayoutAuthenticated.jsx.j2 +39 -0
- jfastframework/templates/frontend_react/src/main.jsx.j2 +12 -0
- jfastframework/templates/frontend_react/src/menuAside.js.j2 +15 -0
- jfastframework/templates/frontend_react/src/router/index.jsx.j2 +20 -0
- jfastframework/templates/frontend_react/src/services/api.js.j2 +29 -0
- jfastframework/templates/frontend_react/src/style.css.j2 +17 -0
- jfastframework/templates/frontend_react/src/views/HomeView.jsx.j2 +58 -0
- jfastframework/templates/frontend_react/vite.config.js.j2 +14 -0
- jfastframework/templates/frontend_vue/.env.example.j2 +2 -0
- jfastframework/templates/frontend_vue/.env.j2 +4 -0
- jfastframework/templates/frontend_vue/.env.production.j2 +7 -0
- jfastframework/templates/frontend_vue/.gitignore.j2 +6 -0
- jfastframework/templates/frontend_vue/README.md.j2 +69 -0
- jfastframework/templates/frontend_vue/index.html.j2 +13 -0
- jfastframework/templates/frontend_vue/package.json.j2 +25 -0
- jfastframework/templates/frontend_vue/src/App.vue.j2 +7 -0
- jfastframework/templates/frontend_vue/src/components/BaseIcon.vue.j2 +22 -0
- jfastframework/templates/frontend_vue/src/components/CardBox.vue.j2 +5 -0
- jfastframework/templates/frontend_vue/src/components/SectionMain.vue.j2 +5 -0
- jfastframework/templates/frontend_vue/src/components/SectionTitleLineWithButton.vue.j2 +22 -0
- jfastframework/templates/frontend_vue/src/layouts/LayoutAuthenticated.vue.j2 +35 -0
- jfastframework/templates/frontend_vue/src/main.js.j2 +8 -0
- jfastframework/templates/frontend_vue/src/menuAside.js.j2 +15 -0
- jfastframework/templates/frontend_vue/src/router/index.js.j2 +33 -0
- jfastframework/templates/frontend_vue/src/services/api.js.j2 +29 -0
- jfastframework/templates/frontend_vue/src/style.css.j2 +17 -0
- jfastframework/templates/frontend_vue/src/views/HomeView.vue.j2 +53 -0
- jfastframework/templates/frontend_vue/vite.config.js.j2 +19 -0
- jfastframework/templates/module_layered/__init__.py.j2 +7 -0
- jfastframework/templates/module_layered/{{module}}/README.md.j2 +48 -0
- jfastframework/templates/module_layered/{{module}}/__init__.py.j2 +22 -0
- jfastframework/templates/module_layered/{{module}}/models.py.j2 +19 -0
- jfastframework/templates/module_layered/{{module}}/repository.py.j2 +22 -0
- jfastframework/templates/module_layered/{{module}}/router.py.j2 +80 -0
- jfastframework/templates/module_layered/{{module}}/schemas.py.j2 +35 -0
- jfastframework/templates/module_layered/{{module}}/service.py.j2 +39 -0
- jfastframework/templates/module_layered/{{module}}/tests/__init__.py.j2 +7 -0
- jfastframework/templates/module_layered/{{module}}/tests/test_{{module}}.py.j2 +103 -0
- jfastframework/templates/module_screaming/__init__.py.j2 +7 -0
- jfastframework/templates/module_screaming/{{module}}/README.md.j2 +66 -0
- jfastframework/templates/module_screaming/{{module}}/__init__.py.j2 +30 -0
- jfastframework/templates/module_screaming/{{module}}/http.py.j2 +102 -0
- jfastframework/templates/module_screaming/{{module}}/storage.py.j2 +41 -0
- jfastframework/templates/module_screaming/{{module}}/tests/__init__.py.j2 +6 -0
- jfastframework/templates/module_screaming/{{module}}/tests/test_{{module}}_domain.py.j2 +45 -0
- jfastframework/templates/module_screaming/{{module}}/tests/test_{{module}}_use_cases.py.j2 +137 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/__init__.py.j2 +54 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/create_{{module}}.py.j2 +33 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/delete_{{module}}.py.j2 +10 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/get_{{module}}.py.j2 +10 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/list_{{module}}.py.j2 +12 -0
- jfastframework/templates/module_screaming/{{module}}/use_cases/update_{{module}}.py.j2 +30 -0
- jfastframework/templates/module_screaming/{{module}}/{{module}}.py.j2 +51 -0
- jfastframework/templates/proto/proto/README.md.j2 +60 -0
- jfastframework/templates/proto/proto/{{service_slug}}.proto.j2 +96 -0
- jfastframework/templates/service_base/.env.example.j2 +66 -0
- jfastframework/templates/service_base/.gitignore.j2 +10 -0
- jfastframework/templates/service_base/README.md.j2 +101 -0
- jfastframework/templates/service_base/alembic.ini.j2 +44 -0
- jfastframework/templates/service_base/conftest.py.j2 +27 -0
- jfastframework/templates/service_base/jfast.toml.j2 +122 -0
- jfastframework/templates/service_base/main.py.j2 +30 -0
- jfastframework/templates/service_base/migrations/env.py.j2 +105 -0
- jfastframework/templates/service_base/migrations/script.py.mako.j2 +31 -0
- jfastframework/templates/service_base/migrations/versions/.gitkeep.j2 +0 -0
- jfastframework/templates/service_base/pytest.ini.j2 +6 -0
- jfastframework/templates/service_base/requirements.txt.j2 +2 -0
- jfastframework/templates/service_gateway/.env.example.j2 +9 -0
- jfastframework/templates/service_gateway/README.md.j2 +56 -0
- jfastframework/templates/service_gateway/jfast.toml.j2 +31 -0
- jfastframework/templates/service_gateway/main.py.j2 +16 -0
- jfastframework/templates/service_gateway/requirements.txt.j2 +1 -0
- jfastframework/templates/service_go/.env.example.j2 +17 -0
- jfastframework/templates/service_go/.gitignore.j2 +4 -0
- jfastframework/templates/service_go/Dockerfile.j2 +25 -0
- jfastframework/templates/service_go/README.md.j2 +81 -0
- jfastframework/templates/service_go/go.mod.j2 +3 -0
- jfastframework/templates/service_go/internal/jfast/config.go.j2 +66 -0
- jfastframework/templates/service_go/internal/jfast/health.go.j2 +112 -0
- jfastframework/templates/service_go/internal/jfast/jfast_test.go.j2 +167 -0
- jfastframework/templates/service_go/internal/jfast/middleware.go.j2 +120 -0
- jfastframework/templates/service_go/internal/jfast/problem.go.j2 +59 -0
- jfastframework/templates/service_go/internal/modules/{{module}}/helpers.go.j2 +26 -0
- jfastframework/templates/service_go/internal/modules/{{module}}/module.go.j2 +155 -0
- jfastframework/templates/service_go/internal/modules/{{module}}/module_test.go.j2 +84 -0
- jfastframework/templates/service_go/jfast.service.toml.j2 +14 -0
- jfastframework/templates/service_go/main.go.j2 +78 -0
- jfastframework/templates/service_web/static/app.css.j2 +116 -0
- jfastframework/templates/service_web/templates/base.html.j2 +37 -0
- jfastframework/templates/service_web/templates/index.html.j2 +19 -0
- jfastframework/templates/service_web/web.py.j2 +18 -0
- jfastframework/templates/ui_htmx/templates/{{module}}/_row.html.j2 +21 -0
- jfastframework/templates/ui_htmx/templates/{{module}}/_rows.html.j2 +13 -0
- jfastframework/templates/ui_htmx/templates/{{module}}/index.html.j2 +43 -0
- jfastframework/templates/ui_htmx/{{modules_dir}}/{{module}}/web.py.j2 +65 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Components/Modals/.gitkeep.j2 +0 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Components/README.md.j2 +12 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Components/Tables/.gitkeep.j2 +0 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Pages/{{View}}View.jsx.j2 +70 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Routes/router.jsx.j2 +10 -0
- jfastframework/templates/view_react/src/Modulo{{View}}/Services/{{view_slug}}.service.js.j2 +34 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Components/Modals/.gitkeep.j2 +0 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Components/README.md.j2 +12 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Components/Tables/.gitkeep.j2 +0 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Pages/{{View}}View.vue.j2 +60 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Routes/router.js.j2 +10 -0
- jfastframework/templates/view_vue/src/Modulo{{View}}/Services/{{view_slug}}.service.js.j2 +34 -0
- jfastframework/testing/__init__.py +25 -0
- jfastframework/testing/fixtures.py +102 -0
- jfastframework/vectors/__init__.py +8 -0
- jfastframework/vectors/base.py +85 -0
- jfastframework/vectors/pgvector.py +147 -0
- jfastframework/vectors/qdrant.py +155 -0
- jfastframework/workspace.py +517 -0
- jfastframework-0.1.0a1.dist-info/METADATA +471 -0
- jfastframework-0.1.0a1.dist-info/RECORD +198 -0
- jfastframework-0.1.0a1.dist-info/WHEEL +4 -0
- jfastframework-0.1.0a1.dist-info/entry_points.txt +20 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""JFastFramework -- plugin-based FastAPI framework for microservices."""
|
|
2
|
+
|
|
3
|
+
from jfastframework.app import create_app, get_context
|
|
4
|
+
from jfastframework.context import AppContext
|
|
5
|
+
from jfastframework.errors import (
|
|
6
|
+
ConflictError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
JFastError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
UnauthorizedError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from jfastframework.plugins.base import (
|
|
15
|
+
HealthReport,
|
|
16
|
+
InfraService,
|
|
17
|
+
Plugin,
|
|
18
|
+
PluginMeta,
|
|
19
|
+
PluginSettings,
|
|
20
|
+
)
|
|
21
|
+
from jfastframework.settings import JFastConfig, JFastSettings
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0a1"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AppContext",
|
|
27
|
+
"ConflictError",
|
|
28
|
+
"ForbiddenError",
|
|
29
|
+
"HealthReport",
|
|
30
|
+
"InfraService",
|
|
31
|
+
"JFastConfig",
|
|
32
|
+
"JFastError",
|
|
33
|
+
"JFastSettings",
|
|
34
|
+
"NotFoundError",
|
|
35
|
+
"Plugin",
|
|
36
|
+
"PluginMeta",
|
|
37
|
+
"PluginSettings",
|
|
38
|
+
"ServiceUnavailableError",
|
|
39
|
+
"UnauthorizedError",
|
|
40
|
+
"ValidationError",
|
|
41
|
+
"__version__",
|
|
42
|
+
"create_app",
|
|
43
|
+
"get_context",
|
|
44
|
+
]
|
jfastframework/app.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Application factory.
|
|
2
|
+
|
|
3
|
+
A JFast service's ``main.py`` is this::
|
|
4
|
+
|
|
5
|
+
from jfastframework import create_app
|
|
6
|
+
|
|
7
|
+
app = create_app()
|
|
8
|
+
|
|
9
|
+
Everything else -- middleware, observability, database, routers -- arrives
|
|
10
|
+
through the plugin graph resolved from ``jfast.toml``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import AsyncIterator, Sequence
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import APIRouter, FastAPI
|
|
22
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
23
|
+
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
24
|
+
|
|
25
|
+
from jfastframework.context import AppContext
|
|
26
|
+
from jfastframework.errors import install_error_handlers
|
|
27
|
+
from jfastframework.health import build_system_router
|
|
28
|
+
from jfastframework.middleware import BodySizeLimitMiddleware, RequestTimeoutMiddleware
|
|
29
|
+
from jfastframework.plugins import registry
|
|
30
|
+
from jfastframework.plugins.base import Plugin
|
|
31
|
+
from jfastframework.settings import DEFAULT_CONFIG_FILE, JFastConfig, JFastSettings
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("jfast")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_app(
|
|
37
|
+
*,
|
|
38
|
+
config: JFastConfig | None = None,
|
|
39
|
+
config_path: str | Path | None = DEFAULT_CONFIG_FILE,
|
|
40
|
+
overrides: dict[str, Any] | None = None,
|
|
41
|
+
plugins: Sequence[type[Plugin]] | None = None,
|
|
42
|
+
routers: Sequence[APIRouter] | None = None,
|
|
43
|
+
) -> FastAPI:
|
|
44
|
+
"""Build a configured FastAPI application.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: Pre-built config. Skips loading ``jfast.toml``.
|
|
48
|
+
config_path: Path to ``jfast.toml``. ``None`` to use env vars only.
|
|
49
|
+
overrides: Kernel settings overrides, highest precedence.
|
|
50
|
+
plugins: Extra plugin classes to register without installing them as
|
|
51
|
+
distributions. Useful in tests and for private in-repo plugins.
|
|
52
|
+
routers: Application routers to mount after plugins have registered.
|
|
53
|
+
"""
|
|
54
|
+
cfg = config or JFastConfig.load(config_path=config_path, overrides=overrides)
|
|
55
|
+
settings = cfg.settings
|
|
56
|
+
|
|
57
|
+
resolved = registry.build(cfg, extra_plugins=list(plugins or []))
|
|
58
|
+
|
|
59
|
+
app = FastAPI(
|
|
60
|
+
title=settings.app_name,
|
|
61
|
+
version=settings.version,
|
|
62
|
+
debug=settings.debug,
|
|
63
|
+
root_path=settings.root_path,
|
|
64
|
+
docs_url=settings.effective_docs_url,
|
|
65
|
+
openapi_url=settings.effective_openapi_url,
|
|
66
|
+
lifespan=_build_lifespan(resolved),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Before plugins, so their middleware runs inside these. A body that is too
|
|
70
|
+
# large should be refused before observability logs it as a request served.
|
|
71
|
+
_install_edge_middleware(app, settings)
|
|
72
|
+
|
|
73
|
+
ctx = AppContext(app=app, config=cfg)
|
|
74
|
+
# Plugins and the lifespan reach the context through app.state; nothing
|
|
75
|
+
# else in the framework uses module-level globals.
|
|
76
|
+
app.state.jfast = ctx
|
|
77
|
+
app.state.plugins = resolved
|
|
78
|
+
|
|
79
|
+
install_error_handlers(app, debug=settings.debug)
|
|
80
|
+
|
|
81
|
+
for plugin in resolved:
|
|
82
|
+
logger.debug("registering plugin %s", plugin.meta.name)
|
|
83
|
+
plugin.register(ctx)
|
|
84
|
+
|
|
85
|
+
app.include_router(build_system_router(ctx, resolved))
|
|
86
|
+
|
|
87
|
+
for router in routers or []:
|
|
88
|
+
app.include_router(router)
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
"%s built with plugins: %s",
|
|
92
|
+
settings.app_name,
|
|
93
|
+
", ".join(p.meta.name for p in resolved) or "<none>",
|
|
94
|
+
)
|
|
95
|
+
return app
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _install_edge_middleware(app: FastAPI, settings: JFastSettings) -> None:
|
|
99
|
+
"""Host validation, CORS, body limits and request timeouts.
|
|
100
|
+
|
|
101
|
+
Starlette applies middleware in reverse registration order, so the last one
|
|
102
|
+
added is the outermost. Registration here therefore reads inside-out:
|
|
103
|
+
timeout and body limit closest to the application, then CORS, then the host
|
|
104
|
+
check outermost -- a request for a host this service does not serve is
|
|
105
|
+
rejected before anything else looks at it, and an error response still
|
|
106
|
+
carries its CORS headers, which is the only way the browser will show it.
|
|
107
|
+
"""
|
|
108
|
+
if settings.request_timeout is not None:
|
|
109
|
+
app.add_middleware(RequestTimeoutMiddleware, seconds=settings.request_timeout)
|
|
110
|
+
|
|
111
|
+
if settings.max_body_bytes is not None:
|
|
112
|
+
app.add_middleware(BodySizeLimitMiddleware, max_bytes=settings.max_body_bytes)
|
|
113
|
+
|
|
114
|
+
if settings.cors_origins:
|
|
115
|
+
app.add_middleware(
|
|
116
|
+
CORSMiddleware,
|
|
117
|
+
allow_origins=settings.cors_origins,
|
|
118
|
+
allow_credentials=settings.cors_allow_credentials,
|
|
119
|
+
allow_methods=settings.cors_allow_methods,
|
|
120
|
+
allow_headers=settings.cors_allow_headers,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if settings.trusted_hosts:
|
|
124
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _build_lifespan(plugins: list[Plugin]): # type: ignore[no-untyped-def]
|
|
128
|
+
@asynccontextmanager
|
|
129
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
130
|
+
ctx: AppContext = app.state.jfast
|
|
131
|
+
started: list[Plugin] = []
|
|
132
|
+
try:
|
|
133
|
+
for plugin in plugins:
|
|
134
|
+
await plugin.startup(ctx)
|
|
135
|
+
started.append(plugin)
|
|
136
|
+
logger.debug("started plugin %s", plugin.meta.name)
|
|
137
|
+
yield
|
|
138
|
+
finally:
|
|
139
|
+
# Reverse order, and one plugin failing to shut down must not
|
|
140
|
+
# prevent the rest from releasing their resources.
|
|
141
|
+
for plugin in reversed(started):
|
|
142
|
+
try:
|
|
143
|
+
await plugin.shutdown(ctx)
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("plugin %s failed to shut down", plugin.meta.name)
|
|
146
|
+
|
|
147
|
+
return lifespan
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_context(app: FastAPI) -> AppContext:
|
|
151
|
+
"""Retrieve the JFast context from a running app."""
|
|
152
|
+
ctx = getattr(app.state, "jfast", None)
|
|
153
|
+
if ctx is None:
|
|
154
|
+
raise RuntimeError("This app was not built by jfastframework.create_app")
|
|
155
|
+
return ctx # type: ignore[no-any-return]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""JWT authentication: verification, scopes, rotation, revocation.
|
|
2
|
+
|
|
3
|
+
from jfastframework.auth import Principal, require_scopes
|
|
4
|
+
|
|
5
|
+
@router.post("/invoices")
|
|
6
|
+
async def create(caller: Principal = Depends(require_scopes("invoices:write"))):
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
The plugin verifies tokens and can mint them. It does not know who your users
|
|
10
|
+
are — there is no login endpoint, because checking a password against your user
|
|
11
|
+
table is your application's job. Use ``auth.issuer`` from your own login route.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from jfastframework.auth.jwks import JWKSClient, JWKSError
|
|
15
|
+
from jfastframework.auth.principal import Principal, current_principal
|
|
16
|
+
from jfastframework.auth.store import MemoryTokenStore, RedisTokenStore, TokenStore
|
|
17
|
+
from jfastframework.auth.tokens import (
|
|
18
|
+
SUPPORTED_ALGORITHMS,
|
|
19
|
+
TokenClaims,
|
|
20
|
+
TokenError,
|
|
21
|
+
issue,
|
|
22
|
+
verify,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"SUPPORTED_ALGORITHMS",
|
|
27
|
+
"JWKSClient",
|
|
28
|
+
"JWKSError",
|
|
29
|
+
"MemoryTokenStore",
|
|
30
|
+
"Principal",
|
|
31
|
+
"RedisTokenStore",
|
|
32
|
+
"TokenClaims",
|
|
33
|
+
"TokenError",
|
|
34
|
+
"TokenStore",
|
|
35
|
+
"current_principal",
|
|
36
|
+
"issue",
|
|
37
|
+
"optional_auth",
|
|
38
|
+
"require_auth",
|
|
39
|
+
"require_roles",
|
|
40
|
+
"require_scopes",
|
|
41
|
+
"verify",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def __getattr__(name: str) -> object:
|
|
46
|
+
"""Expose the FastAPI dependencies without importing the plugin eagerly.
|
|
47
|
+
|
|
48
|
+
``jfastframework.auth`` must stay importable in a process that has no
|
|
49
|
+
FastAPI app — a worker, a script, a test of the token functions alone.
|
|
50
|
+
The dependencies live in the plugin because they read ``request.state``.
|
|
51
|
+
"""
|
|
52
|
+
if name in ("require_auth", "require_scopes", "require_roles", "optional_auth"):
|
|
53
|
+
from jfastframework.plugins.builtin import auth as plugin
|
|
54
|
+
|
|
55
|
+
return getattr(plugin, name)
|
|
56
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""JWKS client: fetch and cache an issuer's public keys.
|
|
2
|
+
|
|
3
|
+
Asymmetric verification is what makes JWT workable across services. The
|
|
4
|
+
identity provider holds the private key; every service fetches the public keys
|
|
5
|
+
from its JWKS endpoint and verifies locally. No shared secret, no network hop
|
|
6
|
+
per request, and key rotation is a publish rather than a redeploy.
|
|
7
|
+
|
|
8
|
+
Two failure modes this guards against, both of which are easy to build in by
|
|
9
|
+
accident:
|
|
10
|
+
|
|
11
|
+
**Refresh amplification.** A token carrying an unknown ``kid`` should trigger a
|
|
12
|
+
refresh -- that is how rotation is picked up. Refreshing on *every* unknown
|
|
13
|
+
``kid`` turns a stream of forged tokens into a denial-of-service against your
|
|
14
|
+
identity provider. Refreshes are therefore rate-limited, and a token whose
|
|
15
|
+
``kid`` is still unknown afterwards is simply rejected.
|
|
16
|
+
|
|
17
|
+
**Serving stale keys forever.** If the endpoint is unreachable, the cached keys
|
|
18
|
+
keep working -- a JWKS outage must not take every service down with it -- but
|
|
19
|
+
the staleness is reported through the health check rather than hidden.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JWKSError(RuntimeError):
|
|
30
|
+
"""The key set could not be fetched or does not contain the key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class JWKSClient:
|
|
35
|
+
url: str
|
|
36
|
+
# How long a fetched key set is considered fresh.
|
|
37
|
+
cache_seconds: int = 3600
|
|
38
|
+
# Floor between refreshes triggered by an unknown kid.
|
|
39
|
+
min_refresh_seconds: int = 60
|
|
40
|
+
timeout: float = 5.0
|
|
41
|
+
|
|
42
|
+
_keys: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
43
|
+
_fetched_at: float = 0.0
|
|
44
|
+
_last_attempt: float = 0.0
|
|
45
|
+
_last_error: str | None = None
|
|
46
|
+
|
|
47
|
+
async def _fetch(self) -> None:
|
|
48
|
+
import httpx
|
|
49
|
+
|
|
50
|
+
self._last_attempt = time.monotonic()
|
|
51
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
52
|
+
response = await client.get(self.url)
|
|
53
|
+
response.raise_for_status()
|
|
54
|
+
document = response.json()
|
|
55
|
+
|
|
56
|
+
keys = {}
|
|
57
|
+
for entry in document.get("keys", []):
|
|
58
|
+
kid = entry.get("kid")
|
|
59
|
+
if kid:
|
|
60
|
+
keys[kid] = entry
|
|
61
|
+
|
|
62
|
+
if not keys:
|
|
63
|
+
raise JWKSError(f"{self.url} returned no usable keys")
|
|
64
|
+
|
|
65
|
+
self._keys = keys
|
|
66
|
+
self._fetched_at = time.monotonic()
|
|
67
|
+
self._last_error = None
|
|
68
|
+
|
|
69
|
+
async def _refresh(self, *, force: bool = False) -> None:
|
|
70
|
+
try:
|
|
71
|
+
await self._fetch()
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
self._last_error = str(exc)
|
|
74
|
+
if force or not self._keys:
|
|
75
|
+
# Nothing cached to fall back on: this request cannot be
|
|
76
|
+
# verified, and saying so beats guessing.
|
|
77
|
+
raise JWKSError(f"cannot fetch JWKS from {self.url}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
async def key_for(self, kid: str | None) -> Any:
|
|
80
|
+
"""The signing key for this ``kid``, fetching the set if needed."""
|
|
81
|
+
now = time.monotonic()
|
|
82
|
+
expired = now - self._fetched_at > self.cache_seconds
|
|
83
|
+
|
|
84
|
+
if not self._keys or expired:
|
|
85
|
+
await self._refresh(force=not self._keys)
|
|
86
|
+
|
|
87
|
+
if kid is None:
|
|
88
|
+
if len(self._keys) == 1:
|
|
89
|
+
return next(iter(self._keys.values()))
|
|
90
|
+
raise JWKSError(
|
|
91
|
+
"the token has no 'kid' and the key set has several keys; "
|
|
92
|
+
"which one signed it is unknowable"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if kid not in self._keys and (now - self._last_attempt) > self.min_refresh_seconds:
|
|
96
|
+
# Unknown kid: the issuer may have rotated. Refresh at most once
|
|
97
|
+
# per window, so forged kids cannot be used to hammer the issuer.
|
|
98
|
+
await self._refresh()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return self._keys[kid]
|
|
102
|
+
except KeyError:
|
|
103
|
+
raise JWKSError(f"no key with kid {kid!r} in {self.url}") from None
|
|
104
|
+
|
|
105
|
+
async def health(self) -> tuple[bool, str]:
|
|
106
|
+
if not self._keys:
|
|
107
|
+
return False, self._last_error or "no keys fetched yet"
|
|
108
|
+
age = int(time.monotonic() - self._fetched_at)
|
|
109
|
+
if self._last_error:
|
|
110
|
+
# Serving cached keys through an outage is correct; hiding that it
|
|
111
|
+
# is happening is not.
|
|
112
|
+
return False, f"serving keys cached {age}s ago; last refresh failed: {self._last_error}"
|
|
113
|
+
return True, f"{len(self._keys)} key(s), cached {age}s ago"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def key_ids(self) -> tuple[str, ...]:
|
|
117
|
+
return tuple(sorted(self._keys))
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Sign in with Google — and any other OIDC provider.
|
|
2
|
+
|
|
3
|
+
The auth plugin already verifies JWTs against a JWKS endpoint, and a Google ID
|
|
4
|
+
token is exactly that: a JWT signed by Google, with Google's keys published at
|
|
5
|
+
a well-known URL. So "log in with Google" needs almost no new machinery, just
|
|
6
|
+
the right issuer, audience and discovery document.
|
|
7
|
+
|
|
8
|
+
The flow, and who does what:
|
|
9
|
+
|
|
10
|
+
1. Your frontend sends the user to Google (:func:`authorization_url`).
|
|
11
|
+
2. Google redirects back with a `code`.
|
|
12
|
+
3. Your backend exchanges it for an **ID token** (:meth:`OIDCProvider.exchange`).
|
|
13
|
+
4. This module verifies that ID token.
|
|
14
|
+
5. **You** look the user up or create them, and mint *your own* token with
|
|
15
|
+
`auth.issuer`.
|
|
16
|
+
|
|
17
|
+
Step 5 is yours on purpose. A Google ID token says "Google believes this is
|
|
18
|
+
person@example.com". It does not say what they may do in your system, it
|
|
19
|
+
expires on Google's schedule, and you cannot revoke it. Exchanging it for your
|
|
20
|
+
own token is what puts scopes, your tenant and your revocation back under your
|
|
21
|
+
control.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import secrets
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import urlencode
|
|
30
|
+
|
|
31
|
+
from jfastframework.auth.jwks import JWKSClient
|
|
32
|
+
from jfastframework.auth.principal import Principal
|
|
33
|
+
from jfastframework.auth.tokens import TokenError, verify
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProviderPreset:
|
|
38
|
+
"""Endpoints for a known provider, so nobody has to look them up."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
issuer: str
|
|
42
|
+
authorization_endpoint: str
|
|
43
|
+
token_endpoint: str
|
|
44
|
+
jwks_uri: str
|
|
45
|
+
default_scopes: tuple[str, ...] = ("openid", "email", "profile")
|
|
46
|
+
algorithms: tuple[str, ...] = ("RS256",)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PRESETS: dict[str, ProviderPreset] = {
|
|
50
|
+
"google": ProviderPreset(
|
|
51
|
+
name="google",
|
|
52
|
+
issuer="https://accounts.google.com",
|
|
53
|
+
authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
|
|
54
|
+
token_endpoint="https://oauth2.googleapis.com/token",
|
|
55
|
+
jwks_uri="https://www.googleapis.com/oauth2/v3/certs",
|
|
56
|
+
),
|
|
57
|
+
"microsoft": ProviderPreset(
|
|
58
|
+
name="microsoft",
|
|
59
|
+
issuer="https://login.microsoftonline.com/common/v2.0",
|
|
60
|
+
authorization_endpoint=("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
|
|
61
|
+
token_endpoint="https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
62
|
+
jwks_uri="https://login.microsoftonline.com/common/discovery/v2.0/keys",
|
|
63
|
+
),
|
|
64
|
+
"github": ProviderPreset(
|
|
65
|
+
name="github",
|
|
66
|
+
# GitHub is OAuth2, not OIDC: no ID token, so the userinfo call is
|
|
67
|
+
# the only way to learn who signed in. Listed for completeness; it
|
|
68
|
+
# does not go through verify_id_token().
|
|
69
|
+
issuer="https://github.com",
|
|
70
|
+
authorization_endpoint="https://github.com/login/oauth/authorize",
|
|
71
|
+
token_endpoint="https://github.com/login/oauth/access_token",
|
|
72
|
+
jwks_uri="",
|
|
73
|
+
default_scopes=("read:user", "user:email"),
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class OIDCIdentity:
|
|
80
|
+
"""Who the provider says this is. Not yet a user in your system."""
|
|
81
|
+
|
|
82
|
+
subject: str
|
|
83
|
+
email: str | None = None
|
|
84
|
+
email_verified: bool = False
|
|
85
|
+
name: str | None = None
|
|
86
|
+
picture: str | None = None
|
|
87
|
+
provider: str = ""
|
|
88
|
+
claims: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def federated_id(self) -> str:
|
|
92
|
+
"""Stable identifier to store on your user row.
|
|
93
|
+
|
|
94
|
+
Provider-qualified, because subject ids are only unique per provider,
|
|
95
|
+
and because a user who later signs in with a different provider must
|
|
96
|
+
not silently collide with someone else.
|
|
97
|
+
"""
|
|
98
|
+
return f"{self.provider}:{self.subject}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OIDCProvider:
|
|
102
|
+
"""One configured provider."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
preset: ProviderPreset,
|
|
107
|
+
*,
|
|
108
|
+
client_id: str,
|
|
109
|
+
client_secret: str = "",
|
|
110
|
+
redirect_uri: str = "",
|
|
111
|
+
scopes: tuple[str, ...] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.preset = preset
|
|
114
|
+
self.client_id = client_id
|
|
115
|
+
self.client_secret = client_secret
|
|
116
|
+
self.redirect_uri = redirect_uri
|
|
117
|
+
self.scopes = scopes or preset.default_scopes
|
|
118
|
+
self._jwks = JWKSClient(url=preset.jwks_uri) if preset.jwks_uri else None
|
|
119
|
+
|
|
120
|
+
# -- step 1 --------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def authorization_url(
|
|
123
|
+
self, *, state: str | None = None, nonce: str | None = None
|
|
124
|
+
) -> tuple[str, str, str]:
|
|
125
|
+
"""Where to send the user. Returns ``(url, state, nonce)``.
|
|
126
|
+
|
|
127
|
+
Keep both values: `state` is checked on the callback (CSRF), and
|
|
128
|
+
`nonce` is checked inside the ID token (replay). Generating them and
|
|
129
|
+
then not verifying them is the same as not having them.
|
|
130
|
+
"""
|
|
131
|
+
state = state or secrets.token_urlsafe(24)
|
|
132
|
+
nonce = nonce or secrets.token_urlsafe(24)
|
|
133
|
+
query = {
|
|
134
|
+
"client_id": self.client_id,
|
|
135
|
+
"redirect_uri": self.redirect_uri,
|
|
136
|
+
"response_type": "code",
|
|
137
|
+
"scope": " ".join(self.scopes),
|
|
138
|
+
"state": state,
|
|
139
|
+
"nonce": nonce,
|
|
140
|
+
# Ask for a refresh token and force the consent screen only when
|
|
141
|
+
# you actually need offline access; both annoy users otherwise.
|
|
142
|
+
}
|
|
143
|
+
return f"{self.preset.authorization_endpoint}?{urlencode(query)}", state, nonce
|
|
144
|
+
|
|
145
|
+
# -- step 3 --------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def exchange(self, code: str) -> dict[str, Any]:
|
|
148
|
+
"""Swap the authorization code for tokens. Server-side only.
|
|
149
|
+
|
|
150
|
+
The client secret must never reach a browser. If your frontend is a
|
|
151
|
+
SPA, this call belongs in your backend, which is where this runs.
|
|
152
|
+
"""
|
|
153
|
+
import httpx
|
|
154
|
+
|
|
155
|
+
if not self.client_secret:
|
|
156
|
+
raise TokenError(f"{self.preset.name}: no client secret configured")
|
|
157
|
+
|
|
158
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
159
|
+
response = await client.post(
|
|
160
|
+
self.preset.token_endpoint,
|
|
161
|
+
data={
|
|
162
|
+
"code": code,
|
|
163
|
+
"client_id": self.client_id,
|
|
164
|
+
"client_secret": self.client_secret,
|
|
165
|
+
"redirect_uri": self.redirect_uri,
|
|
166
|
+
"grant_type": "authorization_code",
|
|
167
|
+
},
|
|
168
|
+
headers={"Accept": "application/json"},
|
|
169
|
+
)
|
|
170
|
+
if response.status_code >= 400:
|
|
171
|
+
# The provider's body often contains the client_id; log the code
|
|
172
|
+
# and a short reason, not the whole payload.
|
|
173
|
+
raise TokenError(f"{self.preset.name} token exchange failed ({response.status_code})")
|
|
174
|
+
payload: dict[str, Any] = response.json()
|
|
175
|
+
return payload
|
|
176
|
+
|
|
177
|
+
# -- step 4 --------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def verify_id_token(self, id_token: str, *, nonce: str | None = None) -> OIDCIdentity:
|
|
180
|
+
"""Verify an ID token and return who it identifies.
|
|
181
|
+
|
|
182
|
+
Audience is this client id, issuer is the provider. Both matter: an ID
|
|
183
|
+
token minted for a *different* application of the same provider is a
|
|
184
|
+
valid Google token, and accepting it lets anyone with their own Google
|
|
185
|
+
app sign in as anybody here.
|
|
186
|
+
"""
|
|
187
|
+
import jwt
|
|
188
|
+
|
|
189
|
+
if self._jwks is None:
|
|
190
|
+
raise TokenError(f"{self.preset.name} does not issue ID tokens")
|
|
191
|
+
|
|
192
|
+
header = jwt.get_unverified_header(id_token)
|
|
193
|
+
jwk = await self._jwks.key_for(header.get("kid"))
|
|
194
|
+
|
|
195
|
+
principal: Principal = verify(
|
|
196
|
+
id_token,
|
|
197
|
+
key=jwt.PyJWK(jwk).key,
|
|
198
|
+
algorithms=list(self.preset.algorithms),
|
|
199
|
+
audience=self.client_id,
|
|
200
|
+
issuer=self.preset.issuer,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if nonce is not None and principal.claims.get("nonce") != nonce:
|
|
204
|
+
# Without this a captured ID token can be replayed into a fresh
|
|
205
|
+
# login attempt.
|
|
206
|
+
raise TokenError("ID token nonce does not match this login attempt")
|
|
207
|
+
|
|
208
|
+
claims = principal.claims
|
|
209
|
+
return OIDCIdentity(
|
|
210
|
+
subject=principal.subject,
|
|
211
|
+
email=claims.get("email"),
|
|
212
|
+
# An unverified email must not be used to match an existing
|
|
213
|
+
# account: it is an account-takeover primitive.
|
|
214
|
+
email_verified=bool(claims.get("email_verified", False)),
|
|
215
|
+
name=claims.get("name"),
|
|
216
|
+
picture=claims.get("picture"),
|
|
217
|
+
provider=self.preset.name,
|
|
218
|
+
claims=claims,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def health(self) -> tuple[bool, str]:
|
|
222
|
+
if self._jwks is None:
|
|
223
|
+
return True, f"{self.preset.name} (OAuth2, no ID tokens)"
|
|
224
|
+
return await self._jwks.health()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def provider(
|
|
228
|
+
name: str,
|
|
229
|
+
*,
|
|
230
|
+
client_id: str,
|
|
231
|
+
client_secret: str = "",
|
|
232
|
+
redirect_uri: str = "",
|
|
233
|
+
issuer: str = "",
|
|
234
|
+
jwks_uri: str = "",
|
|
235
|
+
authorization_endpoint: str = "",
|
|
236
|
+
token_endpoint: str = "",
|
|
237
|
+
) -> OIDCProvider:
|
|
238
|
+
"""A known provider by name, or a custom one from its endpoints."""
|
|
239
|
+
preset = PRESETS.get(name)
|
|
240
|
+
if preset is None:
|
|
241
|
+
missing = [
|
|
242
|
+
field_name
|
|
243
|
+
for field_name, value in (
|
|
244
|
+
("issuer", issuer),
|
|
245
|
+
("jwks_uri", jwks_uri),
|
|
246
|
+
("authorization_endpoint", authorization_endpoint),
|
|
247
|
+
("token_endpoint", token_endpoint),
|
|
248
|
+
)
|
|
249
|
+
if not value
|
|
250
|
+
]
|
|
251
|
+
if missing:
|
|
252
|
+
raise TokenError(
|
|
253
|
+
f"Unknown provider {name!r}. Either use one of "
|
|
254
|
+
f"{', '.join(sorted(PRESETS))}, or supply: {', '.join(missing)}."
|
|
255
|
+
)
|
|
256
|
+
preset = ProviderPreset(
|
|
257
|
+
name=name,
|
|
258
|
+
issuer=issuer,
|
|
259
|
+
authorization_endpoint=authorization_endpoint,
|
|
260
|
+
token_endpoint=token_endpoint,
|
|
261
|
+
jwks_uri=jwks_uri,
|
|
262
|
+
)
|
|
263
|
+
return OIDCProvider(
|
|
264
|
+
preset,
|
|
265
|
+
client_id=client_id,
|
|
266
|
+
client_secret=client_secret,
|
|
267
|
+
redirect_uri=redirect_uri,
|
|
268
|
+
)
|