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.
Files changed (198) hide show
  1. jfastframework/__init__.py +44 -0
  2. jfastframework/app.py +155 -0
  3. jfastframework/auth/__init__.py +56 -0
  4. jfastframework/auth/jwks.py +117 -0
  5. jfastframework/auth/oidc.py +268 -0
  6. jfastframework/auth/principal.py +64 -0
  7. jfastframework/auth/store.py +141 -0
  8. jfastframework/auth/tokens.py +197 -0
  9. jfastframework/cli/__init__.py +5 -0
  10. jfastframework/cli/main.py +1549 -0
  11. jfastframework/cli/patcher.py +160 -0
  12. jfastframework/cli/scaffold.py +478 -0
  13. jfastframework/context.py +69 -0
  14. jfastframework/contracts/__init__.py +36 -0
  15. jfastframework/contracts/_scan.py +141 -0
  16. jfastframework/contracts/blocking.py +425 -0
  17. jfastframework/contracts/checker.py +318 -0
  18. jfastframework/contracts/model.py +306 -0
  19. jfastframework/contracts/render.py +116 -0
  20. jfastframework/db/__init__.py +13 -0
  21. jfastframework/db/base.py +47 -0
  22. jfastframework/db/repository.py +152 -0
  23. jfastframework/deploy/__init__.py +17 -0
  24. jfastframework/deploy/compose.py +182 -0
  25. jfastframework/deploy/kubernetes.py +431 -0
  26. jfastframework/deploy/serverless.py +199 -0
  27. jfastframework/deploy/workspace.py +256 -0
  28. jfastframework/errors.py +141 -0
  29. jfastframework/graph.py +109 -0
  30. jfastframework/health.py +110 -0
  31. jfastframework/languages.py +123 -0
  32. jfastframework/middleware.py +169 -0
  33. jfastframework/plugins/__init__.py +17 -0
  34. jfastframework/plugins/base.py +162 -0
  35. jfastframework/plugins/builtin/__init__.py +11 -0
  36. jfastframework/plugins/builtin/auth.py +698 -0
  37. jfastframework/plugins/builtin/cache.py +148 -0
  38. jfastframework/plugins/builtin/database.py +149 -0
  39. jfastframework/plugins/builtin/events.py +306 -0
  40. jfastframework/plugins/builtin/gateway.py +239 -0
  41. jfastframework/plugins/builtin/metrics.py +162 -0
  42. jfastframework/plugins/builtin/mongo.py +108 -0
  43. jfastframework/plugins/builtin/notifications.py +324 -0
  44. jfastframework/plugins/builtin/observability.py +186 -0
  45. jfastframework/plugins/builtin/qdrant.py +104 -0
  46. jfastframework/plugins/builtin/queue.py +262 -0
  47. jfastframework/plugins/builtin/rag.py +308 -0
  48. jfastframework/plugins/builtin/sentry.py +74 -0
  49. jfastframework/plugins/builtin/storage.py +293 -0
  50. jfastframework/plugins/builtin/tenancy.py +222 -0
  51. jfastframework/plugins/builtin/web.py +209 -0
  52. jfastframework/plugins/registry.py +182 -0
  53. jfastframework/py.typed +0 -0
  54. jfastframework/queues/__init__.py +10 -0
  55. jfastframework/queues/base.py +131 -0
  56. jfastframework/queues/postgres.py +211 -0
  57. jfastframework/queues/rabbitmq.py +139 -0
  58. jfastframework/queues/redis.py +235 -0
  59. jfastframework/queues/worker.py +204 -0
  60. jfastframework/resources.py +241 -0
  61. jfastframework/secrets.py +172 -0
  62. jfastframework/settings.py +173 -0
  63. jfastframework/sql.py +45 -0
  64. jfastframework/storage/__init__.py +38 -0
  65. jfastframework/storage/base.py +198 -0
  66. jfastframework/storage/local.py +192 -0
  67. jfastframework/storage/s3.py +295 -0
  68. jfastframework/templates/contracts_layered/contracts.toml.j2 +123 -0
  69. jfastframework/templates/contracts_screaming/contracts.toml.j2 +125 -0
  70. jfastframework/templates/frontend_react/.env.example.j2 +4 -0
  71. jfastframework/templates/frontend_react/.env.j2 +4 -0
  72. jfastframework/templates/frontend_react/.env.production.j2 +7 -0
  73. jfastframework/templates/frontend_react/.gitignore.j2 +6 -0
  74. jfastframework/templates/frontend_react/README.md.j2 +69 -0
  75. jfastframework/templates/frontend_react/index.html.j2 +13 -0
  76. jfastframework/templates/frontend_react/package.json.j2 +25 -0
  77. jfastframework/templates/frontend_react/src/components/BaseIcon.jsx.j2 +13 -0
  78. jfastframework/templates/frontend_react/src/components/CardBox.jsx.j2 +9 -0
  79. jfastframework/templates/frontend_react/src/components/SectionMain.jsx.j2 +3 -0
  80. jfastframework/templates/frontend_react/src/components/SectionTitleLineWithButton.jsx.j2 +18 -0
  81. jfastframework/templates/frontend_react/src/layouts/LayoutAuthenticated.jsx.j2 +39 -0
  82. jfastframework/templates/frontend_react/src/main.jsx.j2 +12 -0
  83. jfastframework/templates/frontend_react/src/menuAside.js.j2 +15 -0
  84. jfastframework/templates/frontend_react/src/router/index.jsx.j2 +20 -0
  85. jfastframework/templates/frontend_react/src/services/api.js.j2 +29 -0
  86. jfastframework/templates/frontend_react/src/style.css.j2 +17 -0
  87. jfastframework/templates/frontend_react/src/views/HomeView.jsx.j2 +58 -0
  88. jfastframework/templates/frontend_react/vite.config.js.j2 +14 -0
  89. jfastframework/templates/frontend_vue/.env.example.j2 +2 -0
  90. jfastframework/templates/frontend_vue/.env.j2 +4 -0
  91. jfastframework/templates/frontend_vue/.env.production.j2 +7 -0
  92. jfastframework/templates/frontend_vue/.gitignore.j2 +6 -0
  93. jfastframework/templates/frontend_vue/README.md.j2 +69 -0
  94. jfastframework/templates/frontend_vue/index.html.j2 +13 -0
  95. jfastframework/templates/frontend_vue/package.json.j2 +25 -0
  96. jfastframework/templates/frontend_vue/src/App.vue.j2 +7 -0
  97. jfastframework/templates/frontend_vue/src/components/BaseIcon.vue.j2 +22 -0
  98. jfastframework/templates/frontend_vue/src/components/CardBox.vue.j2 +5 -0
  99. jfastframework/templates/frontend_vue/src/components/SectionMain.vue.j2 +5 -0
  100. jfastframework/templates/frontend_vue/src/components/SectionTitleLineWithButton.vue.j2 +22 -0
  101. jfastframework/templates/frontend_vue/src/layouts/LayoutAuthenticated.vue.j2 +35 -0
  102. jfastframework/templates/frontend_vue/src/main.js.j2 +8 -0
  103. jfastframework/templates/frontend_vue/src/menuAside.js.j2 +15 -0
  104. jfastframework/templates/frontend_vue/src/router/index.js.j2 +33 -0
  105. jfastframework/templates/frontend_vue/src/services/api.js.j2 +29 -0
  106. jfastframework/templates/frontend_vue/src/style.css.j2 +17 -0
  107. jfastframework/templates/frontend_vue/src/views/HomeView.vue.j2 +53 -0
  108. jfastframework/templates/frontend_vue/vite.config.js.j2 +19 -0
  109. jfastframework/templates/module_layered/__init__.py.j2 +7 -0
  110. jfastframework/templates/module_layered/{{module}}/README.md.j2 +48 -0
  111. jfastframework/templates/module_layered/{{module}}/__init__.py.j2 +22 -0
  112. jfastframework/templates/module_layered/{{module}}/models.py.j2 +19 -0
  113. jfastframework/templates/module_layered/{{module}}/repository.py.j2 +22 -0
  114. jfastframework/templates/module_layered/{{module}}/router.py.j2 +80 -0
  115. jfastframework/templates/module_layered/{{module}}/schemas.py.j2 +35 -0
  116. jfastframework/templates/module_layered/{{module}}/service.py.j2 +39 -0
  117. jfastframework/templates/module_layered/{{module}}/tests/__init__.py.j2 +7 -0
  118. jfastframework/templates/module_layered/{{module}}/tests/test_{{module}}.py.j2 +103 -0
  119. jfastframework/templates/module_screaming/__init__.py.j2 +7 -0
  120. jfastframework/templates/module_screaming/{{module}}/README.md.j2 +66 -0
  121. jfastframework/templates/module_screaming/{{module}}/__init__.py.j2 +30 -0
  122. jfastframework/templates/module_screaming/{{module}}/http.py.j2 +102 -0
  123. jfastframework/templates/module_screaming/{{module}}/storage.py.j2 +41 -0
  124. jfastframework/templates/module_screaming/{{module}}/tests/__init__.py.j2 +6 -0
  125. jfastframework/templates/module_screaming/{{module}}/tests/test_{{module}}_domain.py.j2 +45 -0
  126. jfastframework/templates/module_screaming/{{module}}/tests/test_{{module}}_use_cases.py.j2 +137 -0
  127. jfastframework/templates/module_screaming/{{module}}/use_cases/__init__.py.j2 +54 -0
  128. jfastframework/templates/module_screaming/{{module}}/use_cases/create_{{module}}.py.j2 +33 -0
  129. jfastframework/templates/module_screaming/{{module}}/use_cases/delete_{{module}}.py.j2 +10 -0
  130. jfastframework/templates/module_screaming/{{module}}/use_cases/get_{{module}}.py.j2 +10 -0
  131. jfastframework/templates/module_screaming/{{module}}/use_cases/list_{{module}}.py.j2 +12 -0
  132. jfastframework/templates/module_screaming/{{module}}/use_cases/update_{{module}}.py.j2 +30 -0
  133. jfastframework/templates/module_screaming/{{module}}/{{module}}.py.j2 +51 -0
  134. jfastframework/templates/proto/proto/README.md.j2 +60 -0
  135. jfastframework/templates/proto/proto/{{service_slug}}.proto.j2 +96 -0
  136. jfastframework/templates/service_base/.env.example.j2 +66 -0
  137. jfastframework/templates/service_base/.gitignore.j2 +10 -0
  138. jfastframework/templates/service_base/README.md.j2 +101 -0
  139. jfastframework/templates/service_base/alembic.ini.j2 +44 -0
  140. jfastframework/templates/service_base/conftest.py.j2 +27 -0
  141. jfastframework/templates/service_base/jfast.toml.j2 +122 -0
  142. jfastframework/templates/service_base/main.py.j2 +30 -0
  143. jfastframework/templates/service_base/migrations/env.py.j2 +105 -0
  144. jfastframework/templates/service_base/migrations/script.py.mako.j2 +31 -0
  145. jfastframework/templates/service_base/migrations/versions/.gitkeep.j2 +0 -0
  146. jfastframework/templates/service_base/pytest.ini.j2 +6 -0
  147. jfastframework/templates/service_base/requirements.txt.j2 +2 -0
  148. jfastframework/templates/service_gateway/.env.example.j2 +9 -0
  149. jfastframework/templates/service_gateway/README.md.j2 +56 -0
  150. jfastframework/templates/service_gateway/jfast.toml.j2 +31 -0
  151. jfastframework/templates/service_gateway/main.py.j2 +16 -0
  152. jfastframework/templates/service_gateway/requirements.txt.j2 +1 -0
  153. jfastframework/templates/service_go/.env.example.j2 +17 -0
  154. jfastframework/templates/service_go/.gitignore.j2 +4 -0
  155. jfastframework/templates/service_go/Dockerfile.j2 +25 -0
  156. jfastframework/templates/service_go/README.md.j2 +81 -0
  157. jfastframework/templates/service_go/go.mod.j2 +3 -0
  158. jfastframework/templates/service_go/internal/jfast/config.go.j2 +66 -0
  159. jfastframework/templates/service_go/internal/jfast/health.go.j2 +112 -0
  160. jfastframework/templates/service_go/internal/jfast/jfast_test.go.j2 +167 -0
  161. jfastframework/templates/service_go/internal/jfast/middleware.go.j2 +120 -0
  162. jfastframework/templates/service_go/internal/jfast/problem.go.j2 +59 -0
  163. jfastframework/templates/service_go/internal/modules/{{module}}/helpers.go.j2 +26 -0
  164. jfastframework/templates/service_go/internal/modules/{{module}}/module.go.j2 +155 -0
  165. jfastframework/templates/service_go/internal/modules/{{module}}/module_test.go.j2 +84 -0
  166. jfastframework/templates/service_go/jfast.service.toml.j2 +14 -0
  167. jfastframework/templates/service_go/main.go.j2 +78 -0
  168. jfastframework/templates/service_web/static/app.css.j2 +116 -0
  169. jfastframework/templates/service_web/templates/base.html.j2 +37 -0
  170. jfastframework/templates/service_web/templates/index.html.j2 +19 -0
  171. jfastframework/templates/service_web/web.py.j2 +18 -0
  172. jfastframework/templates/ui_htmx/templates/{{module}}/_row.html.j2 +21 -0
  173. jfastframework/templates/ui_htmx/templates/{{module}}/_rows.html.j2 +13 -0
  174. jfastframework/templates/ui_htmx/templates/{{module}}/index.html.j2 +43 -0
  175. jfastframework/templates/ui_htmx/{{modules_dir}}/{{module}}/web.py.j2 +65 -0
  176. jfastframework/templates/view_react/src/Modulo{{View}}/Components/Modals/.gitkeep.j2 +0 -0
  177. jfastframework/templates/view_react/src/Modulo{{View}}/Components/README.md.j2 +12 -0
  178. jfastframework/templates/view_react/src/Modulo{{View}}/Components/Tables/.gitkeep.j2 +0 -0
  179. jfastframework/templates/view_react/src/Modulo{{View}}/Pages/{{View}}View.jsx.j2 +70 -0
  180. jfastframework/templates/view_react/src/Modulo{{View}}/Routes/router.jsx.j2 +10 -0
  181. jfastframework/templates/view_react/src/Modulo{{View}}/Services/{{view_slug}}.service.js.j2 +34 -0
  182. jfastframework/templates/view_vue/src/Modulo{{View}}/Components/Modals/.gitkeep.j2 +0 -0
  183. jfastframework/templates/view_vue/src/Modulo{{View}}/Components/README.md.j2 +12 -0
  184. jfastframework/templates/view_vue/src/Modulo{{View}}/Components/Tables/.gitkeep.j2 +0 -0
  185. jfastframework/templates/view_vue/src/Modulo{{View}}/Pages/{{View}}View.vue.j2 +60 -0
  186. jfastframework/templates/view_vue/src/Modulo{{View}}/Routes/router.js.j2 +10 -0
  187. jfastframework/templates/view_vue/src/Modulo{{View}}/Services/{{view_slug}}.service.js.j2 +34 -0
  188. jfastframework/testing/__init__.py +25 -0
  189. jfastframework/testing/fixtures.py +102 -0
  190. jfastframework/vectors/__init__.py +8 -0
  191. jfastframework/vectors/base.py +85 -0
  192. jfastframework/vectors/pgvector.py +147 -0
  193. jfastframework/vectors/qdrant.py +155 -0
  194. jfastframework/workspace.py +517 -0
  195. jfastframework-0.1.0a1.dist-info/METADATA +471 -0
  196. jfastframework-0.1.0a1.dist-info/RECORD +198 -0
  197. jfastframework-0.1.0a1.dist-info/WHEEL +4 -0
  198. 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
+ )