supython 0.1.12__tar.gz → 0.1.14__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.
- {supython-0.1.12 → supython-0.1.14}/CHANGELOG.md +52 -0
- {supython-0.1.12 → supython-0.1.14}/PKG-INFO +1 -1
- {supython-0.1.12 → supython-0.1.14}/pyproject.toml +1 -1
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/router.py +48 -8
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/schemas.py +13 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/service.py +173 -14
- {supython-0.1.12 → supython-0.1.14}/src/supython/cli.py +10 -5
- supython-0.1.14/src/supython/migrations/0016_magic_link_redirect.sql +15 -0
- supython-0.1.14/src/supython/migrations/0017_auth_activated_at.sql +37 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/settings.py +13 -0
- {supython-0.1.12 → supython-0.1.14}/.gitignore +0 -0
- {supython-0.1.12 → supython-0.1.14}/LICENSE +0 -0
- {supython-0.1.12 → supython-0.1.14}/README.md +0 -0
- {supython-0.1.12 → supython-0.1.14}/SECURITY.md +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth_templates.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth_users.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/db.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/functions.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/jobs.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/ops.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/realtime.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth_templates.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth_users.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_db.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_functions.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_jobs.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_ops.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_realtime.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_storage.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/storage.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/system.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/audit.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/deps.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/errors.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/session.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/spa.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Alert-dluGVkos.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Alert-dluGVkos.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Audit-Njung3HI.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Audit-Njung3HI.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Backups-DzPlFgrm.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Backups-DzPlFgrm.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Buckets-ByacGkU1.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Buckets-ByacGkU1.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Channels-BoIuTtam.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Channels-BoIuTtam.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Crons-B67vc39F.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Crons-B67vc39F.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DataTable-COAAWEft.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DataTable-COAAWEft.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Empty-cr2r7e2u.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Empty-cr2r7e2u.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Grid-hFkp9F4P.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Grid-hFkp9F4P.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Input-DppYTq9C.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Input-DppYTq9C.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/JsonField-DibyJgun.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/JsonField-DibyJgun.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-BjLyE3Ds.css +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-CoOjECT_.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-CoOjECT_.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-D9WYrnIT.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-D9WYrnIT.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-DS1XPa0h.css +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Queue-CywZs6vI.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Queue-CywZs6vI.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-BiLXE49D.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-BiLXE49D.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-C-ianIGD.css +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Select-DIzZyRZb.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Select-DIzZyRZb.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Space-n5-XcguU.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Space-n5-XcguU.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/TableData-CQIagLKn.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/TableData-CQIagLKn.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Tag-D1fOKpTH.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Tag-D1fOKpTH.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Templates-BS-ugkdq.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Templates-BS-ugkdq.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Thing-CEAniuMg.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Thing-CEAniuMg.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Users-wzwajhlh.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Users-wzwajhlh.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/dist-VXIJLCYq.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/dist-VXIJLCYq.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/format-length-CGCY1rMh.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/format-length-CGCY1rMh.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/get-Ca6unauB.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/get-Ca6unauB.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/index-CeE6v959.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/index-CeE6v959.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/pinia-COXwfrOX.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/pinia-COXwfrOX.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/resources-Bt6thQCD.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/resources-Bt6thQCD.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useResource-C_rJCY8C.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useResource-C_rJCY8C.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-CnZc5zhi.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-CnZc5zhi.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-Dg0XlRlq.css +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useToast-DsZKx0IX.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useToast-DsZKx0IX.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/utils-sbXoq7Ir.js +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/utils-sbXoq7Ir.js.map +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/favicon.svg +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/icons.svg +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/index.html +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/app.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/_email_job.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/claims.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/deps.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/github.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/google.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/oauth.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/registry.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/auth/ratelimit.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/backups/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/backups/_backup_job.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/backups/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/backups/service.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/body_size.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/_auth.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/_client.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/_config.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/_functions.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/_storage.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/client/py.typed +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/db.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/db_admin.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/extensions.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/functions/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/functions/context.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/functions/loader.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/functions/router.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/functions/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/gen/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/gen/_introspect.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/gen/types_py.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/gen/types_ts.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/health.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/hooks.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/backends.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/context.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/cron.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/cron_inproc.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/decorators.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/registry.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/router.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/service.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/worker.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/jwks.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/keyset.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/logging_config.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/mail.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/mailer.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrate.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0001_extensions_and_roles.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0002_auth_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0004_auth_v0_2.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0005_storage_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0006_realtime_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0007_jobs_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0008_jobs_last_error.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0009_auth_rate_limits.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0010_worker_heartbeat.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0011_admin_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0012_auth_banned_until.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0013_email_templates.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0014_realtime_payload_warning.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0015_backups_schema.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/passwords.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/broker.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/protocol.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/router.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/service.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/topics.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/websocket.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/init_project.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/Caddyfile.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/README.md.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/apps_hooks_welcome.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/apps_jobs_example.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/asgi.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker-compose.prod.yml.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker-compose.yml.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/env.example.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/functions_README.md.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/gitignore.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/manage.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_hooks_init.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_init.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_jobs_init.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/pyproject.toml.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/settings.py.tmpl +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/static/0001_create_todos.sql +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/static/functions_hello.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/secretset.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/security_headers.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/settings_module.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/__init__.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/backends.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/router.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/schemas.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/service.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/storage/signing.py +0 -0
- {supython-0.1.12 → supython-0.1.14}/src/supython/tokens.py +0 -0
|
@@ -30,6 +30,57 @@ Each entry links the relevant `PROJECT.md` section and decision-log row
|
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
33
|
+
## [0.1.14] — 2026-07-17
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- Account activation gate for pre-created users (GHSA-27m9-35j7-7g5f B). New
|
|
37
|
+
`auth.users.activated_at` column (migration `0017`) plus
|
|
38
|
+
`supython.auth.service.activate_user(conn, user_id)`: a consumer that
|
|
39
|
+
pre-creates an `auth.users` row (e.g. an invite flow that provisions the user
|
|
40
|
+
plus a role/membership up front) can keep it from authenticating through the
|
|
41
|
+
passwordless endpoints until an explicit activation step. Self-serve signup
|
|
42
|
+
and OAuth sign-in activate inline; existing rows are backfilled to
|
|
43
|
+
`created_at` so live users are unaffected.
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
- Enforce account eligibility at session issuance (GHSA-27m9-35j7-7g5f). Every
|
|
47
|
+
grant type — password, refresh, magic-link, OTP, recover, OAuth — now checks
|
|
48
|
+
account eligibility before minting a token pair, at the `_issue_pair` funnel
|
|
49
|
+
and in `refresh_grant` (which mints its own pair), so an in-flight session
|
|
50
|
+
also dies at its next refresh. A banned account (`banned_until` in the future)
|
|
51
|
+
gets `403 account_disabled`; a not-yet-activated account (`activated_at is
|
|
52
|
+
null`) gets `403 account_inactive`. The `request_*` endpoints stay
|
|
53
|
+
enumeration-resistant (still `202` for a banned or inactive email).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## [0.1.13] — 2026-07-16
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- `POST /auth/v1/magiclink` accepts an optional `redirect_url`: when its origin
|
|
61
|
+
is in the new `MAGIC_LINK_REDIRECT_ALLOWLIST` setting, `GET
|
|
62
|
+
/auth/v1/magiclink/verify` 302-redirects the browser to it with the token
|
|
63
|
+
pair in the fragment (the same shape as the OAuth callback), instead of
|
|
64
|
+
returning JSON. Omitting `redirect_url` keeps the existing JSON response.
|
|
65
|
+
Lets a consumer send a magic link straight to its own SPA route (e.g. an
|
|
66
|
+
invite-accept page) rather than the API's bare verify endpoint.
|
|
67
|
+
- `POST /auth/v1/magiclink` accepts an optional `ttl` (seconds), clamped to
|
|
68
|
+
`[60, MAGIC_LINK_MAX_TTL]` (new setting, default 7 days). Lets a single
|
|
69
|
+
caller mint a longer-lived link (e.g. a multi-day operator invite) without
|
|
70
|
+
moving `MAGIC_LINK_TOKEN_TTL`, which every other magic-link request still
|
|
71
|
+
uses unchanged.
|
|
72
|
+
- Migration `0016_magic_link_redirect.sql`: nullable `auth.one_time_tokens.redirect_url`.
|
|
73
|
+
|
|
74
|
+
### Security
|
|
75
|
+
- `redirect_url` is validated against an explicit origin allowlist before any
|
|
76
|
+
email is sent (empty allowlist ⇒ every redirect is rejected, fail-closed);
|
|
77
|
+
a non-allowlisted origin, embedded credentials (`user:pass@host`), or a
|
|
78
|
+
non-http(s) scheme all reject the request with `400 invalid_redirect`. The
|
|
79
|
+
target is stored server-side on the one-time token, not echoed through the
|
|
80
|
+
emailed link, so an intercepted link can't be repointed at a different origin.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
33
84
|
## [0.1.12] — 2026-07-01
|
|
34
85
|
|
|
35
86
|
### Fixed
|
|
@@ -318,6 +369,7 @@ v0.1–v0.7 plus a v1.1.x admin track; see §19 decision log
|
|
|
318
369
|
---
|
|
319
370
|
|
|
320
371
|
|
|
372
|
+
[0.1.13]: https://github.com/Tkeby/supython/releases/tag/v0.1.13
|
|
321
373
|
[0.1.12]: https://github.com/Tkeby/supython/releases/tag/v0.1.12
|
|
322
374
|
[0.1.11]: https://github.com/Tkeby/supython/releases/tag/v0.1.11
|
|
323
375
|
[0.1.10]: https://github.com/Tkeby/supython/releases/tag/v0.1.10
|
|
@@ -2,8 +2,18 @@ from typing import Annotated
|
|
|
2
2
|
from uuid import UUID
|
|
3
3
|
|
|
4
4
|
import jwt
|
|
5
|
-
from fastapi import
|
|
6
|
-
|
|
5
|
+
from fastapi import (
|
|
6
|
+
APIRouter,
|
|
7
|
+
Depends,
|
|
8
|
+
Header,
|
|
9
|
+
HTTPException,
|
|
10
|
+
Query,
|
|
11
|
+
Request,
|
|
12
|
+
Response,
|
|
13
|
+
status,
|
|
14
|
+
)
|
|
15
|
+
from fastapi.encoders import jsonable_encoder
|
|
16
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
7
17
|
|
|
8
18
|
from .. import db, tokens
|
|
9
19
|
from ..settings import get_settings
|
|
@@ -198,24 +208,54 @@ async def verify_recover(payload: RecoverVerifyRequest, request: Request) -> Tok
|
|
|
198
208
|
|
|
199
209
|
@router.post("/magiclink", status_code=202, dependencies=[Depends(_rl_magiclink)])
|
|
200
210
|
async def request_magic_link(payload: MagicLinkRequest) -> None:
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
try:
|
|
212
|
+
async with db.acquire() as conn:
|
|
213
|
+
await service.request_magic_link(
|
|
214
|
+
conn,
|
|
215
|
+
payload.email,
|
|
216
|
+
redirect_url=payload.redirect_url,
|
|
217
|
+
ttl=payload.ttl,
|
|
218
|
+
)
|
|
219
|
+
except service.AuthError as exc:
|
|
220
|
+
# Currently only invalid_redirect (400) — a bad email is filtered by
|
|
221
|
+
# EmailStr at the schema layer before this handler ever runs.
|
|
222
|
+
raise _auth_error(exc) from exc
|
|
203
223
|
|
|
204
224
|
|
|
205
225
|
@router.get(
|
|
206
226
|
"/magiclink/verify",
|
|
207
|
-
response_model=TokenResponse,
|
|
208
227
|
dependencies=[Depends(_rl_magiclink)],
|
|
209
228
|
)
|
|
210
229
|
async def verify_magic_link(
|
|
211
230
|
token: Annotated[str, Query(description="Raw magic-link token from the email")],
|
|
212
|
-
) ->
|
|
231
|
+
) -> Response:
|
|
232
|
+
"""Verify a magic-link token.
|
|
233
|
+
|
|
234
|
+
Legacy/JSON callers (no ``redirect_url`` at request time) get the same
|
|
235
|
+
``TokenResponse`` body as always. A caller that requested a redirect gets a
|
|
236
|
+
302 to that URL with the token pair in the fragment — the same shape as the
|
|
237
|
+
OAuth callback (`oauth_callback` below) — so a browser landing here from an
|
|
238
|
+
emailed link ends up on the SPA's own page with a session, not looking at
|
|
239
|
+
raw JSON.
|
|
240
|
+
"""
|
|
213
241
|
try:
|
|
214
242
|
async with db.acquire() as conn:
|
|
215
|
-
|
|
243
|
+
user, access, refresh, ttl, redirect_url = await service.verify_magic_link(
|
|
244
|
+
conn, token
|
|
245
|
+
)
|
|
216
246
|
except service.AuthError as exc:
|
|
217
247
|
raise _auth_error(exc) from exc
|
|
218
|
-
|
|
248
|
+
if redirect_url:
|
|
249
|
+
fragment = (
|
|
250
|
+
f"access_token={access}"
|
|
251
|
+
f"&refresh_token={refresh}"
|
|
252
|
+
f"&expires_in={ttl}"
|
|
253
|
+
f"&token_type=bearer"
|
|
254
|
+
)
|
|
255
|
+
return RedirectResponse(f"{redirect_url}#{fragment}", status_code=302)
|
|
256
|
+
return JSONResponse(
|
|
257
|
+
jsonable_encoder(_to_token_response(user, access, refresh, ttl))
|
|
258
|
+
)
|
|
219
259
|
|
|
220
260
|
|
|
221
261
|
@router.post("/otp", status_code=202, dependencies=[Depends(_rl_otp)])
|
|
@@ -17,6 +17,8 @@ MAX_PASSWORD_LEN = 128
|
|
|
17
17
|
# chars in practice). 4096 keeps a generous head-room for any future
|
|
18
18
|
# encoding while still rejecting "1 MB token" abuse.
|
|
19
19
|
MAX_TOKEN_LEN = 4096
|
|
20
|
+
# Redirect URLs are ordinary web URLs; 2048 is the de-facto browser cap.
|
|
21
|
+
MAX_REDIRECT_URL_LEN = 2048
|
|
20
22
|
|
|
21
23
|
EmailField = Annotated[EmailStr, Field(max_length=MAX_EMAIL_LEN)]
|
|
22
24
|
PasswordField = Annotated[
|
|
@@ -68,6 +70,17 @@ class RecoverVerifyRequest(BaseModel):
|
|
|
68
70
|
|
|
69
71
|
class MagicLinkRequest(BaseModel):
|
|
70
72
|
email: EmailField
|
|
73
|
+
# Where /auth/v1/magiclink/verify should send the browser after issuing
|
|
74
|
+
# tokens: a 302 to this URL with the token pair in the fragment (as OAuth
|
|
75
|
+
# does), instead of a JSON response. Must match MAGIC_LINK_REDIRECT_
|
|
76
|
+
# ALLOWLIST or the request is rejected 400. Omit for the JSON flow.
|
|
77
|
+
redirect_url: Annotated[
|
|
78
|
+
str | None, Field(default=None, max_length=MAX_REDIRECT_URL_LEN)
|
|
79
|
+
] = None
|
|
80
|
+
# Per-request token lifetime in seconds, clamped to [60, MAGIC_LINK_MAX_TTL].
|
|
81
|
+
# Omit to use MAGIC_LINK_TOKEN_TTL. Lets a caller mint a longer-lived link
|
|
82
|
+
# (e.g. an operator invite) without changing the global default.
|
|
83
|
+
ttl: Annotated[int | None, Field(default=None, ge=1)] = None
|
|
71
84
|
|
|
72
85
|
|
|
73
86
|
class OtpRequest(BaseModel):
|
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
import secrets
|
|
7
7
|
from datetime import UTC, datetime, timedelta
|
|
8
8
|
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
9
10
|
from uuid import UUID
|
|
10
11
|
|
|
11
12
|
import asyncpg
|
|
@@ -61,9 +62,46 @@ async def _audit_log(
|
|
|
61
62
|
)
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
async def _assert_can_authenticate(
|
|
66
|
+
conn: asyncpg.Connection, user_id: UUID
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Refuse session issuance for an account that is not eligible to sign in.
|
|
69
|
+
|
|
70
|
+
Enforced at the issuance funnel so every grant type — password, refresh,
|
|
71
|
+
magic-link, OTP, recover, OAuth — is gated by the same rules:
|
|
72
|
+
|
|
73
|
+
- ``banned_until`` in the future ⇒ ``account_disabled`` (403), deliberately
|
|
74
|
+
distinct from ``invalid_credentials`` (401) so a caller can tell "wrong
|
|
75
|
+
password" apart from "correct credentials, but locked out".
|
|
76
|
+
- ``activated_at is null`` ⇒ ``account_inactive`` (403). A consumer can
|
|
77
|
+
pre-create an ``auth.users`` row (e.g. an invite flow that provisions the
|
|
78
|
+
user plus a role/membership up front) that must not authenticate until an
|
|
79
|
+
explicit ``activate_user`` call; signup and OAuth sign-in activate inline.
|
|
80
|
+
|
|
81
|
+
Gating at issuance (rather than only at password verification) means the
|
|
82
|
+
check also lands on the passwordless verifies and on ``refresh_grant``, so
|
|
83
|
+
an in-flight session dies at its next refresh — near-immediate revocation
|
|
84
|
+
with no token blocklist.
|
|
85
|
+
"""
|
|
86
|
+
row = await conn.fetchrow(
|
|
87
|
+
"select banned_until, activated_at from auth.users where id = $1",
|
|
88
|
+
user_id,
|
|
89
|
+
)
|
|
90
|
+
if row is None:
|
|
91
|
+
# The row vanished between the caller's lookup and issuance (e.g. a
|
|
92
|
+
# concurrent delete). Fail closed rather than mint an orphan session.
|
|
93
|
+
raise AuthError("account_disabled", "Account is not eligible to sign in", 403)
|
|
94
|
+
banned_until = row["banned_until"]
|
|
95
|
+
if banned_until is not None and banned_until > datetime.now(UTC):
|
|
96
|
+
raise AuthError("account_disabled", "Account is disabled", 403)
|
|
97
|
+
if row["activated_at"] is None:
|
|
98
|
+
raise AuthError("account_inactive", "Account is not activated", 403)
|
|
99
|
+
|
|
100
|
+
|
|
64
101
|
async def _issue_pair(
|
|
65
102
|
conn: asyncpg.Connection, user: UserResponse
|
|
66
103
|
) -> tuple[str, str, int]:
|
|
104
|
+
await _assert_can_authenticate(conn, user.id)
|
|
67
105
|
extra = await claims.collect(user, conn)
|
|
68
106
|
access, ttl = tokens.issue_access_token(
|
|
69
107
|
user.id, user.email, extra_claims=extra or None
|
|
@@ -82,19 +120,26 @@ async def _store_one_time_token(
|
|
|
82
120
|
user_id: UUID,
|
|
83
121
|
token_type: str,
|
|
84
122
|
ttl_seconds: int,
|
|
123
|
+
*,
|
|
124
|
+
redirect_url: str | None = None,
|
|
85
125
|
) -> str:
|
|
86
|
-
"""Generate, sha256-hash, and store a one-time token. Returns the raw token.
|
|
126
|
+
"""Generate, sha256-hash, and store a one-time token. Returns the raw token.
|
|
127
|
+
|
|
128
|
+
``redirect_url`` is stored only for magic-link tokens (recover/otp pass
|
|
129
|
+
None ⇒ NULL); verify reads it back to decide between a 302 and JSON.
|
|
130
|
+
"""
|
|
87
131
|
raw = secrets.token_urlsafe(32)
|
|
88
132
|
expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
|
89
133
|
await conn.execute(
|
|
90
134
|
"""
|
|
91
|
-
insert into auth.one_time_tokens (user_id, type, token_hash, expires_at)
|
|
92
|
-
values ($1, $2, $3, $4)
|
|
135
|
+
insert into auth.one_time_tokens (user_id, type, token_hash, expires_at, redirect_url)
|
|
136
|
+
values ($1, $2, $3, $4, $5)
|
|
93
137
|
""",
|
|
94
138
|
user_id,
|
|
95
139
|
token_type,
|
|
96
140
|
_sha256(raw),
|
|
97
141
|
expires_at,
|
|
142
|
+
redirect_url,
|
|
98
143
|
)
|
|
99
144
|
return raw
|
|
100
145
|
|
|
@@ -110,7 +155,7 @@ async def _verify_one_time_token(
|
|
|
110
155
|
if email is not None:
|
|
111
156
|
row = await conn.fetchrow(
|
|
112
157
|
"""
|
|
113
|
-
select ott.id as ott_id, u.id, u.email, u.created_at
|
|
158
|
+
select ott.id as ott_id, ott.redirect_url, u.id, u.email, u.created_at
|
|
114
159
|
from auth.one_time_tokens ott
|
|
115
160
|
join auth.users u on u.id = ott.user_id
|
|
116
161
|
where ott.token_hash = $1
|
|
@@ -126,7 +171,7 @@ async def _verify_one_time_token(
|
|
|
126
171
|
else:
|
|
127
172
|
row = await conn.fetchrow(
|
|
128
173
|
"""
|
|
129
|
-
select ott.id as ott_id, u.id, u.email, u.created_at
|
|
174
|
+
select ott.id as ott_id, ott.redirect_url, u.id, u.email, u.created_at
|
|
130
175
|
from auth.one_time_tokens ott
|
|
131
176
|
join auth.users u on u.id = ott.user_id
|
|
132
177
|
where ott.token_hash = $1
|
|
@@ -155,8 +200,8 @@ async def signup(
|
|
|
155
200
|
pw_hash = passwords.hash_password(password)
|
|
156
201
|
row = await conn.fetchrow(
|
|
157
202
|
"""
|
|
158
|
-
insert into auth.users (email, encrypted_password, email_confirmed_at)
|
|
159
|
-
values ($1, $2, now())
|
|
203
|
+
insert into auth.users (email, encrypted_password, email_confirmed_at, activated_at)
|
|
204
|
+
values ($1, $2, now(), now())
|
|
160
205
|
returning id, email, created_at
|
|
161
206
|
""",
|
|
162
207
|
email,
|
|
@@ -197,12 +242,14 @@ async def password_grant(
|
|
|
197
242
|
logger.warning("auth.password_grant: bad password for %s", email)
|
|
198
243
|
raise AuthError("invalid_credentials", "Invalid email or password", 401)
|
|
199
244
|
|
|
245
|
+
user = _row_to_user(row)
|
|
246
|
+
# Issue first: _issue_pair raises account_disabled (403) for a banned user,
|
|
247
|
+
# so we don't stamp last_sign_in_at for a sign-in that was refused.
|
|
248
|
+
access, refresh, ttl = await _issue_pair(conn, user)
|
|
200
249
|
await conn.execute(
|
|
201
250
|
"update auth.users set last_sign_in_at = now() where id = $1",
|
|
202
251
|
row["id"],
|
|
203
252
|
)
|
|
204
|
-
user = _row_to_user(row)
|
|
205
|
-
access, refresh, ttl = await _issue_pair(conn, user)
|
|
206
253
|
return user, access, refresh, ttl
|
|
207
254
|
|
|
208
255
|
|
|
@@ -264,6 +311,10 @@ async def refresh_grant(
|
|
|
264
311
|
)
|
|
265
312
|
|
|
266
313
|
user = _row_to_user(row)
|
|
314
|
+
# refresh_grant mints its own pair rather than routing through _issue_pair,
|
|
315
|
+
# so the eligibility gate has to be applied here too. Check before rotating:
|
|
316
|
+
# a banned user is refused without burning their current refresh token.
|
|
317
|
+
await _assert_can_authenticate(conn, user.id)
|
|
267
318
|
new_refresh = tokens.issue_refresh_token()
|
|
268
319
|
async with conn.transaction():
|
|
269
320
|
await conn.execute(
|
|
@@ -301,6 +352,36 @@ async def get_user(
|
|
|
301
352
|
return _row_to_user(row) if row else None
|
|
302
353
|
|
|
303
354
|
|
|
355
|
+
async def activate_user(conn: asyncpg.Connection, user_id: UUID) -> None:
|
|
356
|
+
"""Mark a pre-created account eligible to authenticate.
|
|
357
|
+
|
|
358
|
+
A consumer that provisions an ``auth.users`` row up front (e.g. an invite
|
|
359
|
+
flow that creates the user plus a role/membership before the person has set
|
|
360
|
+
up credentials) calls this at its intended activation step. Until then the
|
|
361
|
+
row's ``activated_at`` is null and every session-issuing path refuses it with
|
|
362
|
+
``account_inactive`` (403). Self-serve signup and OAuth sign-in activate
|
|
363
|
+
inline, so this is only needed for externally pre-created rows.
|
|
364
|
+
|
|
365
|
+
Idempotent: activating an already-active user is a no-op and does not move
|
|
366
|
+
the existing ``activated_at``. Raises ``AuthError('user_not_found', 404)``
|
|
367
|
+
when no such user exists, so a mistyped id surfaces instead of silently
|
|
368
|
+
doing nothing.
|
|
369
|
+
"""
|
|
370
|
+
exists = await conn.fetchval(
|
|
371
|
+
"select 1 from auth.users where id = $1", user_id
|
|
372
|
+
)
|
|
373
|
+
if not exists:
|
|
374
|
+
raise AuthError("user_not_found", "User not found", 404)
|
|
375
|
+
await conn.execute(
|
|
376
|
+
"""
|
|
377
|
+
update auth.users
|
|
378
|
+
set activated_at = now()
|
|
379
|
+
where id = $1 and activated_at is null
|
|
380
|
+
""",
|
|
381
|
+
user_id,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
304
385
|
async def request_recover(
|
|
305
386
|
conn: asyncpg.Connection, email: str
|
|
306
387
|
) -> None:
|
|
@@ -354,17 +435,85 @@ async def verify_recover(
|
|
|
354
435
|
return user, access, refresh, ttl
|
|
355
436
|
|
|
356
437
|
|
|
438
|
+
def _url_origin(url: str) -> str | None:
|
|
439
|
+
"""Return the ``scheme://host[:port]`` origin of an absolute http(s) URL.
|
|
440
|
+
|
|
441
|
+
Returns None for anything that isn't a well-formed absolute http(s) URL, or
|
|
442
|
+
that carries embedded credentials (``user:pass@host``) — those have no place
|
|
443
|
+
in a redirect target and are a classic obfuscation vector. Origins are
|
|
444
|
+
lower-cased so comparison is case-insensitive on scheme/host.
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
parts = urlsplit(url)
|
|
448
|
+
except ValueError:
|
|
449
|
+
return None
|
|
450
|
+
if parts.scheme not in ("http", "https") or not parts.netloc or "@" in parts.netloc:
|
|
451
|
+
return None
|
|
452
|
+
return f"{parts.scheme}://{parts.netloc}".lower()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def validate_magic_link_redirect(redirect_url: str, allowlist_csv: str) -> str:
|
|
456
|
+
"""Return ``redirect_url`` when its origin is allowlisted, else raise.
|
|
457
|
+
|
|
458
|
+
``allowlist_csv`` is the comma-separated ``MAGIC_LINK_REDIRECT_ALLOWLIST``
|
|
459
|
+
setting. An empty allowlist rejects every redirect (the feature is off and
|
|
460
|
+
fails closed). Matching is by origin only, so any path/query/fragment under
|
|
461
|
+
an allowlisted origin is accepted. Raises ``AuthError('invalid_redirect')``
|
|
462
|
+
(400) on any miss — the caller turns that into an HTTP 400.
|
|
463
|
+
"""
|
|
464
|
+
origin = _url_origin(redirect_url)
|
|
465
|
+
if origin is None:
|
|
466
|
+
raise AuthError(
|
|
467
|
+
"invalid_redirect", "redirect_url must be an absolute http(s) URL", 400
|
|
468
|
+
)
|
|
469
|
+
allowed = {
|
|
470
|
+
o for entry in allowlist_csv.split(",") if (o := _url_origin(entry.strip()))
|
|
471
|
+
}
|
|
472
|
+
if origin not in allowed:
|
|
473
|
+
raise AuthError(
|
|
474
|
+
"invalid_redirect", "redirect_url origin is not allowlisted", 400
|
|
475
|
+
)
|
|
476
|
+
return redirect_url
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _clamp_magic_link_ttl(ttl: int | None, settings: Any) -> int:
|
|
480
|
+
"""Resolve the effective magic-link TTL in seconds.
|
|
481
|
+
|
|
482
|
+
``None`` ⇒ the ``magic_link_token_ttl`` default. A supplied value is clamped
|
|
483
|
+
to ``[60, magic_link_max_ttl]`` so a caller can neither mint a link that
|
|
484
|
+
expires too fast to click nor one that outlives the configured ceiling.
|
|
485
|
+
"""
|
|
486
|
+
if ttl is None:
|
|
487
|
+
return settings.magic_link_token_ttl
|
|
488
|
+
return max(60, min(ttl, settings.magic_link_max_ttl))
|
|
489
|
+
|
|
490
|
+
|
|
357
491
|
async def request_magic_link(
|
|
358
|
-
conn: asyncpg.Connection,
|
|
492
|
+
conn: asyncpg.Connection,
|
|
493
|
+
email: str,
|
|
494
|
+
*,
|
|
495
|
+
redirect_url: str | None = None,
|
|
496
|
+
ttl: int | None = None,
|
|
359
497
|
) -> None:
|
|
498
|
+
s = get_settings()
|
|
499
|
+
# Validate the redirect before the user lookup: a bad redirect is a client
|
|
500
|
+
# error regardless of whether the email exists, and checking it here keeps
|
|
501
|
+
# the email-enumeration silence below intact (unknown emails still 202).
|
|
502
|
+
if redirect_url is not None:
|
|
503
|
+
validate_magic_link_redirect(redirect_url, s.magic_link_redirect_allowlist)
|
|
360
504
|
row = await conn.fetchrow(
|
|
361
505
|
"select id from auth.users where email = $1", email
|
|
362
506
|
)
|
|
363
507
|
if not row:
|
|
364
508
|
return
|
|
365
|
-
s = get_settings()
|
|
366
509
|
async with conn.transaction():
|
|
367
|
-
raw = await _store_one_time_token(
|
|
510
|
+
raw = await _store_one_time_token(
|
|
511
|
+
conn,
|
|
512
|
+
row["id"],
|
|
513
|
+
"magic_link",
|
|
514
|
+
_clamp_magic_link_ttl(ttl, s),
|
|
515
|
+
redirect_url=redirect_url,
|
|
516
|
+
)
|
|
368
517
|
verify_url = f"{s.site_url}/auth/v1/magiclink/verify?token={raw}"
|
|
369
518
|
await mail.dispatch(
|
|
370
519
|
conn,
|
|
@@ -379,7 +528,7 @@ async def request_magic_link(
|
|
|
379
528
|
|
|
380
529
|
async def verify_magic_link(
|
|
381
530
|
conn: asyncpg.Connection, token: str
|
|
382
|
-
) -> tuple[UserResponse, str, str, int]:
|
|
531
|
+
) -> tuple[UserResponse, str, str, int, str | None]:
|
|
383
532
|
row = await _verify_one_time_token(conn, token, "magic_link")
|
|
384
533
|
user = _row_to_user(row)
|
|
385
534
|
async with conn.transaction():
|
|
@@ -388,7 +537,9 @@ async def verify_magic_link(
|
|
|
388
537
|
row["ott_id"],
|
|
389
538
|
)
|
|
390
539
|
access, refresh, ttl = await _issue_pair(conn, user)
|
|
391
|
-
|
|
540
|
+
# redirect_url is None for legacy/JSON callers; the router 302-redirects
|
|
541
|
+
# (OAuth-style, tokens in the fragment) only when it is set.
|
|
542
|
+
return user, access, refresh, ttl, row["redirect_url"]
|
|
392
543
|
|
|
393
544
|
|
|
394
545
|
async def request_otp(
|
|
@@ -590,5 +741,13 @@ async def oauth_finish(
|
|
|
590
741
|
},
|
|
591
742
|
)
|
|
592
743
|
|
|
744
|
+
# A successful OAuth exchange proves control of the external account, so it
|
|
745
|
+
# activates the user inline (no-op if already active). This also lets a
|
|
746
|
+
# consumer's pre-created invite row, matched here by email, come online via
|
|
747
|
+
# its first OAuth sign-in.
|
|
748
|
+
await conn.execute(
|
|
749
|
+
"update auth.users set activated_at = now() where id = $1 and activated_at is null",
|
|
750
|
+
user.id,
|
|
751
|
+
)
|
|
593
752
|
access, refresh, ttl = await _issue_pair(conn, user)
|
|
594
753
|
return user, access, refresh, ttl, redirect_uri
|
|
@@ -1487,7 +1487,7 @@ def _bootstrap_test_db(timeout: int) -> None:
|
|
|
1487
1487
|
The named volume in `docker-compose.test.yml` makes the second call
|
|
1488
1488
|
cheap — schema survives between pytest sessions.
|
|
1489
1489
|
"""
|
|
1490
|
-
_compose_with(_TEST_COMPOSE_FILE, "up", "-d", "--wait", "db")
|
|
1490
|
+
_compose_with(_TEST_COMPOSE_FILE, "up", "-d", "--wait", "db", project_directory=".")
|
|
1491
1491
|
|
|
1492
1492
|
prev = os.environ.get("DATABASE_URL")
|
|
1493
1493
|
os.environ["DATABASE_URL"] = _TEST_DATABASE_URL
|
|
@@ -1531,7 +1531,7 @@ def test_up(
|
|
|
1531
1531
|
@test_cli.command("down")
|
|
1532
1532
|
def test_down() -> None:
|
|
1533
1533
|
"""Stop the test DB container. Keeps the volume (and migrations)."""
|
|
1534
|
-
_compose_with(_TEST_COMPOSE_FILE, "down")
|
|
1534
|
+
_compose_with(_TEST_COMPOSE_FILE, "down", project_directory=".")
|
|
1535
1535
|
|
|
1536
1536
|
|
|
1537
1537
|
@test_cli.command("reset")
|
|
@@ -1540,7 +1540,7 @@ def test_reset() -> None:
|
|
|
1540
1540
|
confirm = typer.confirm("This will delete the test DB volume. Continue?")
|
|
1541
1541
|
if not confirm:
|
|
1542
1542
|
raise typer.Abort()
|
|
1543
|
-
_compose_with(_TEST_COMPOSE_FILE, "down", "-v")
|
|
1543
|
+
_compose_with(_TEST_COMPOSE_FILE, "down", "-v", project_directory=".")
|
|
1544
1544
|
|
|
1545
1545
|
|
|
1546
1546
|
@test_cli.command(
|
|
@@ -1667,7 +1667,7 @@ def _ensure_jwks_for_postgrest(s: Settings) -> None:
|
|
|
1667
1667
|
typer.echo(f" JWT_JWKS_PATH={s.jwt_jwks_path}")
|
|
1668
1668
|
|
|
1669
1669
|
|
|
1670
|
-
_TEST_COMPOSE_FILE = "docker-compose.test.yml"
|
|
1670
|
+
_TEST_COMPOSE_FILE = "tests/docker-compose.test.yml"
|
|
1671
1671
|
_TEST_DATABASE_URL = "postgresql://supython:supython@localhost:54323/supython"
|
|
1672
1672
|
|
|
1673
1673
|
|
|
@@ -1676,12 +1676,17 @@ def _compose(*args: str) -> None:
|
|
|
1676
1676
|
|
|
1677
1677
|
|
|
1678
1678
|
def _compose_with(
|
|
1679
|
-
file: str | None,
|
|
1679
|
+
file: str | None,
|
|
1680
|
+
*args: str,
|
|
1681
|
+
profiles: tuple[str, ...] = (),
|
|
1682
|
+
project_directory: str | None = None,
|
|
1680
1683
|
) -> None:
|
|
1681
1684
|
"""Run `docker compose [-f <file>] [--profile <p>]... <args...>`, surfacing common errors."""
|
|
1682
1685
|
cmd = ["docker", "compose"]
|
|
1683
1686
|
if file:
|
|
1684
1687
|
cmd += ["-f", file]
|
|
1688
|
+
if project_directory:
|
|
1689
|
+
cmd += ["--project-directory", project_directory]
|
|
1685
1690
|
for p in profiles:
|
|
1686
1691
|
cmd += ["--profile", p]
|
|
1687
1692
|
cmd += list(args)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Magic-link redirect target.
|
|
2
|
+
--
|
|
3
|
+
-- When a POST /auth/v1/magiclink request carries a redirect_url (validated
|
|
4
|
+
-- against MAGIC_LINK_REDIRECT_ALLOWLIST at request time), it is stored on the
|
|
5
|
+
-- one-time token here. GET /auth/v1/magiclink/verify then 302-redirects the
|
|
6
|
+
-- browser to that SPA URL with the token pair in the fragment — mirroring the
|
|
7
|
+
-- OAuth callback — instead of returning JSON. Storing the target server-side
|
|
8
|
+
-- (rather than echoing it in the emailed link) means an attacker who intercepts
|
|
9
|
+
-- the link cannot repoint it at a token-stealing origin.
|
|
10
|
+
--
|
|
11
|
+
-- Null ⇒ legacy behaviour: verify returns a JSON TokenResponse. Backfill-safe
|
|
12
|
+
-- (nullable, no default), so existing rows keep working.
|
|
13
|
+
|
|
14
|
+
alter table auth.one_time_tokens
|
|
15
|
+
add column if not exists redirect_url text;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Add a per-user activation gate. A consumer can pre-create an auth.users row
|
|
2
|
+
-- (e.g. an invite flow that provisions the user plus a role/membership before
|
|
3
|
+
-- the person has credentials); such a row must not authenticate through the
|
|
4
|
+
-- passwordless endpoints (magiclink / otp / recover) before the intended
|
|
5
|
+
-- activation step. `activated_at` is null while the account is inactive;
|
|
6
|
+
-- supython.auth.service._assert_can_authenticate refuses session issuance for
|
|
7
|
+
-- those rows with `account_inactive` (403).
|
|
8
|
+
--
|
|
9
|
+
-- Activation is explicit: supython.auth.service.activate_user(conn, user_id)
|
|
10
|
+
-- stamps it at the consumer's chosen step, while signup and OAuth sign-in stamp
|
|
11
|
+
-- it inline for self-serve users. Existing rows are backfilled to `created_at`
|
|
12
|
+
-- so live users keep authenticating across the migration.
|
|
13
|
+
--
|
|
14
|
+
-- The column-add and one-time backfill run together, guarded on the column not
|
|
15
|
+
-- yet existing, so a re-run can never re-activate a row that was intentionally
|
|
16
|
+
-- left inactive after the column was introduced.
|
|
17
|
+
|
|
18
|
+
do $$
|
|
19
|
+
begin
|
|
20
|
+
if not exists (
|
|
21
|
+
select 1
|
|
22
|
+
from information_schema.columns
|
|
23
|
+
where table_schema = 'auth'
|
|
24
|
+
and table_name = 'users'
|
|
25
|
+
and column_name = 'activated_at'
|
|
26
|
+
) then
|
|
27
|
+
alter table auth.users
|
|
28
|
+
add column activated_at timestamptz;
|
|
29
|
+
|
|
30
|
+
update auth.users
|
|
31
|
+
set activated_at = created_at;
|
|
32
|
+
end if;
|
|
33
|
+
end $$;
|
|
34
|
+
|
|
35
|
+
create index if not exists users_activated_at_idx
|
|
36
|
+
on auth.users (activated_at)
|
|
37
|
+
where activated_at is null;
|
|
@@ -82,6 +82,19 @@ class Settings(BaseSettings):
|
|
|
82
82
|
recover_token_ttl: int = 3600
|
|
83
83
|
magic_link_token_ttl: int = 15 * 60
|
|
84
84
|
otp_token_ttl: int = 10 * 60
|
|
85
|
+
|
|
86
|
+
# Ceiling for a per-request magic-link lifetime. A POST /auth/v1/magiclink
|
|
87
|
+
# may pass its own `ttl` (e.g. a multi-day operator invite) without moving
|
|
88
|
+
# the magic_link_token_ttl default that ordinary sign-in links use; the
|
|
89
|
+
# requested value is clamped to [60, magic_link_max_ttl].
|
|
90
|
+
magic_link_max_ttl: int = 60 * 60 * 24 * 7 # 7 days
|
|
91
|
+
|
|
92
|
+
# Comma-separated origins (scheme://host[:port]) a magic-link `redirect_url`
|
|
93
|
+
# may target. Empty ⇒ redirects are disabled and verify keeps returning
|
|
94
|
+
# JSON. A redirect_url whose origin is not listed is rejected 400 at request
|
|
95
|
+
# time, so an attacker can't aim a victim's emailed link at a token-stealing
|
|
96
|
+
# site. Example: MAGIC_LINK_REDIRECT_ALLOWLIST=https://app.example.com,http://localhost:5173
|
|
97
|
+
magic_link_redirect_allowlist: str = ""
|
|
85
98
|
auth_rate_limit_enabled: bool = True
|
|
86
99
|
auth_rate_limit_window_seconds: int = 60
|
|
87
100
|
auth_rate_limit_token_per_window: int = 10
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|