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.
Files changed (258) hide show
  1. {supython-0.1.12 → supython-0.1.14}/CHANGELOG.md +52 -0
  2. {supython-0.1.12 → supython-0.1.14}/PKG-INFO +1 -1
  3. {supython-0.1.12 → supython-0.1.14}/pyproject.toml +1 -1
  4. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/router.py +48 -8
  5. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/schemas.py +13 -0
  6. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/service.py +173 -14
  7. {supython-0.1.12 → supython-0.1.14}/src/supython/cli.py +10 -5
  8. supython-0.1.14/src/supython/migrations/0016_magic_link_redirect.sql +15 -0
  9. supython-0.1.14/src/supython/migrations/0017_auth_activated_at.sql +37 -0
  10. {supython-0.1.12 → supython-0.1.14}/src/supython/settings.py +13 -0
  11. {supython-0.1.12 → supython-0.1.14}/.gitignore +0 -0
  12. {supython-0.1.12 → supython-0.1.14}/LICENSE +0 -0
  13. {supython-0.1.12 → supython-0.1.14}/README.md +0 -0
  14. {supython-0.1.12 → supython-0.1.14}/SECURITY.md +0 -0
  15. {supython-0.1.12 → supython-0.1.14}/src/supython/__init__.py +0 -0
  16. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/__init__.py +0 -0
  17. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/__init__.py +0 -0
  18. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth.py +0 -0
  19. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth_templates.py +0 -0
  20. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/auth_users.py +0 -0
  21. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/db.py +0 -0
  22. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/functions.py +0 -0
  23. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/jobs.py +0 -0
  24. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/ops.py +0 -0
  25. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/realtime.py +0 -0
  26. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth.py +0 -0
  27. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth_templates.py +0 -0
  28. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_auth_users.py +0 -0
  29. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_db.py +0 -0
  30. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_functions.py +0 -0
  31. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_jobs.py +0 -0
  32. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_ops.py +0 -0
  33. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_realtime.py +0 -0
  34. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/service_storage.py +0 -0
  35. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/storage.py +0 -0
  36. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/api/system.py +0 -0
  37. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/audit.py +0 -0
  38. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/deps.py +0 -0
  39. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/errors.py +0 -0
  40. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/schemas.py +0 -0
  41. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/session.py +0 -0
  42. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/spa.py +0 -0
  43. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Alert-dluGVkos.js +0 -0
  44. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Alert-dluGVkos.js.map +0 -0
  45. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Audit-Njung3HI.js +0 -0
  46. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Audit-Njung3HI.js.map +0 -0
  47. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Backups-DzPlFgrm.js +0 -0
  48. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Backups-DzPlFgrm.js.map +0 -0
  49. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Buckets-ByacGkU1.js +0 -0
  50. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Buckets-ByacGkU1.js.map +0 -0
  51. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Channels-BoIuTtam.js +0 -0
  52. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Channels-BoIuTtam.js.map +0 -0
  53. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +0 -0
  54. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ChevronRight-CtQH1EQ1.js.map +0 -0
  55. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +0 -0
  56. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/CodeViewer-Bqy7-wvH.js.map +0 -0
  57. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Crons-B67vc39F.js +0 -0
  58. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Crons-B67vc39F.js.map +0 -0
  59. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js +0 -0
  60. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DashboardView-CUTFVL6k.js.map +0 -0
  61. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DataTable-COAAWEft.js +0 -0
  62. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DataTable-COAAWEft.js.map +0 -0
  63. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +0 -0
  64. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js.map +0 -0
  65. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js +0 -0
  66. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/DrawerContent-TpYTFgF1.js.map +0 -0
  67. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Empty-cr2r7e2u.js +0 -0
  68. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Empty-cr2r7e2u.js.map +0 -0
  69. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js +0 -0
  70. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/EmptyState-DeDck-OL.js.map +0 -0
  71. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Grid-hFkp9F4P.js +0 -0
  72. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Grid-hFkp9F4P.js.map +0 -0
  73. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Input-DppYTq9C.js +0 -0
  74. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Input-DppYTq9C.js.map +0 -0
  75. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js +0 -0
  76. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Invoke-DW3Nveeh.js.map +0 -0
  77. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/JsonField-DibyJgun.js +0 -0
  78. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/JsonField-DibyJgun.js.map +0 -0
  79. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-BjLyE3Ds.css +0 -0
  80. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-CoOjECT_.js +0 -0
  81. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/LoginView-CoOjECT_.js.map +0 -0
  82. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-D9WYrnIT.js +0 -0
  83. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-D9WYrnIT.js.map +0 -0
  84. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Logs-DS1XPa0h.css +0 -0
  85. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js +0 -0
  86. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Migrations-DOSC2ddQ.js.map +0 -0
  87. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +0 -0
  88. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js.map +0 -0
  89. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Queue-CywZs6vI.js +0 -0
  90. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Queue-CywZs6vI.js.map +0 -0
  91. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +0 -0
  92. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RefreshTokens-Ccjr53jg.js.map +0 -0
  93. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js +0 -0
  94. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/RlsEditor-BSlH9vSc.js.map +0 -0
  95. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-BiLXE49D.js +0 -0
  96. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-BiLXE49D.js.map +0 -0
  97. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Routes-C-ianIGD.css +0 -0
  98. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +0 -0
  99. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +0 -0
  100. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js.map +0 -0
  101. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Select-DIzZyRZb.js +0 -0
  102. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Select-DIzZyRZb.js.map +0 -0
  103. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Space-n5-XcguU.js +0 -0
  104. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Space-n5-XcguU.js.map +0 -0
  105. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js +0 -0
  106. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlEditor-b8pTsILY.js.map +0 -0
  107. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +0 -0
  108. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/SqlWorkspace-BUS7IntH.js.map +0 -0
  109. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/TableData-CQIagLKn.js +0 -0
  110. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/TableData-CQIagLKn.js.map +0 -0
  111. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Tag-D1fOKpTH.js +0 -0
  112. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Tag-D1fOKpTH.js.map +0 -0
  113. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Templates-BS-ugkdq.js +0 -0
  114. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Templates-BS-ugkdq.js.map +0 -0
  115. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Thing-CEAniuMg.js +0 -0
  116. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Thing-CEAniuMg.js.map +0 -0
  117. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Users-wzwajhlh.js +0 -0
  118. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/Users-wzwajhlh.js.map +0 -0
  119. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +0 -0
  120. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/dist-VXIJLCYq.js +0 -0
  121. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/dist-VXIJLCYq.js.map +0 -0
  122. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/format-length-CGCY1rMh.js +0 -0
  123. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/format-length-CGCY1rMh.js.map +0 -0
  124. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/get-Ca6unauB.js +0 -0
  125. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/get-Ca6unauB.js.map +0 -0
  126. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/index-CeE6v959.js +0 -0
  127. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/index-CeE6v959.js.map +0 -0
  128. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/pinia-COXwfrOX.js +0 -0
  129. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/pinia-COXwfrOX.js.map +0 -0
  130. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/resources-Bt6thQCD.js +0 -0
  131. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/resources-Bt6thQCD.js.map +0 -0
  132. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js +0 -0
  133. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-locale-mtgM0a3a.js.map +0 -0
  134. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js +0 -0
  135. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/use-merged-state-BvhkaHNX.js.map +0 -0
  136. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js +0 -0
  137. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useConfirm-tMjvBFXR.js.map +0 -0
  138. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useResource-C_rJCY8C.js +0 -0
  139. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useResource-C_rJCY8C.js.map +0 -0
  140. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-CnZc5zhi.js +0 -0
  141. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-CnZc5zhi.js.map +0 -0
  142. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useTable-Dg0XlRlq.css +0 -0
  143. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useToast-DsZKx0IX.js +0 -0
  144. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/useToast-DsZKx0IX.js.map +0 -0
  145. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/utils-sbXoq7Ir.js +0 -0
  146. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/assets/utils-sbXoq7Ir.js.map +0 -0
  147. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/favicon.svg +0 -0
  148. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/icons.svg +0 -0
  149. {supython-0.1.12 → supython-0.1.14}/src/supython/admin/static/index.html +0 -0
  150. {supython-0.1.12 → supython-0.1.14}/src/supython/app.py +0 -0
  151. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/__init__.py +0 -0
  152. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/_email_job.py +0 -0
  153. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/claims.py +0 -0
  154. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/deps.py +0 -0
  155. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/__init__.py +0 -0
  156. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/github.py +0 -0
  157. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/google.py +0 -0
  158. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/oauth.py +0 -0
  159. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/providers/registry.py +0 -0
  160. {supython-0.1.12 → supython-0.1.14}/src/supython/auth/ratelimit.py +0 -0
  161. {supython-0.1.12 → supython-0.1.14}/src/supython/backups/__init__.py +0 -0
  162. {supython-0.1.12 → supython-0.1.14}/src/supython/backups/_backup_job.py +0 -0
  163. {supython-0.1.12 → supython-0.1.14}/src/supython/backups/schemas.py +0 -0
  164. {supython-0.1.12 → supython-0.1.14}/src/supython/backups/service.py +0 -0
  165. {supython-0.1.12 → supython-0.1.14}/src/supython/body_size.py +0 -0
  166. {supython-0.1.12 → supython-0.1.14}/src/supython/client/__init__.py +0 -0
  167. {supython-0.1.12 → supython-0.1.14}/src/supython/client/_auth.py +0 -0
  168. {supython-0.1.12 → supython-0.1.14}/src/supython/client/_client.py +0 -0
  169. {supython-0.1.12 → supython-0.1.14}/src/supython/client/_config.py +0 -0
  170. {supython-0.1.12 → supython-0.1.14}/src/supython/client/_functions.py +0 -0
  171. {supython-0.1.12 → supython-0.1.14}/src/supython/client/_storage.py +0 -0
  172. {supython-0.1.12 → supython-0.1.14}/src/supython/client/py.typed +0 -0
  173. {supython-0.1.12 → supython-0.1.14}/src/supython/db.py +0 -0
  174. {supython-0.1.12 → supython-0.1.14}/src/supython/db_admin.py +0 -0
  175. {supython-0.1.12 → supython-0.1.14}/src/supython/extensions.py +0 -0
  176. {supython-0.1.12 → supython-0.1.14}/src/supython/functions/__init__.py +0 -0
  177. {supython-0.1.12 → supython-0.1.14}/src/supython/functions/context.py +0 -0
  178. {supython-0.1.12 → supython-0.1.14}/src/supython/functions/loader.py +0 -0
  179. {supython-0.1.12 → supython-0.1.14}/src/supython/functions/router.py +0 -0
  180. {supython-0.1.12 → supython-0.1.14}/src/supython/functions/schemas.py +0 -0
  181. {supython-0.1.12 → supython-0.1.14}/src/supython/gen/__init__.py +0 -0
  182. {supython-0.1.12 → supython-0.1.14}/src/supython/gen/_introspect.py +0 -0
  183. {supython-0.1.12 → supython-0.1.14}/src/supython/gen/types_py.py +0 -0
  184. {supython-0.1.12 → supython-0.1.14}/src/supython/gen/types_ts.py +0 -0
  185. {supython-0.1.12 → supython-0.1.14}/src/supython/health.py +0 -0
  186. {supython-0.1.12 → supython-0.1.14}/src/supython/hooks.py +0 -0
  187. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/__init__.py +0 -0
  188. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/backends.py +0 -0
  189. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/context.py +0 -0
  190. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/cron.py +0 -0
  191. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/cron_inproc.py +0 -0
  192. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/decorators.py +0 -0
  193. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/registry.py +0 -0
  194. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/router.py +0 -0
  195. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/schemas.py +0 -0
  196. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/service.py +0 -0
  197. {supython-0.1.12 → supython-0.1.14}/src/supython/jobs/worker.py +0 -0
  198. {supython-0.1.12 → supython-0.1.14}/src/supython/jwks.py +0 -0
  199. {supython-0.1.12 → supython-0.1.14}/src/supython/keyset.py +0 -0
  200. {supython-0.1.12 → supython-0.1.14}/src/supython/logging_config.py +0 -0
  201. {supython-0.1.12 → supython-0.1.14}/src/supython/mail.py +0 -0
  202. {supython-0.1.12 → supython-0.1.14}/src/supython/mailer.py +0 -0
  203. {supython-0.1.12 → supython-0.1.14}/src/supython/migrate.py +0 -0
  204. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0001_extensions_and_roles.sql +0 -0
  205. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0002_auth_schema.sql +0 -0
  206. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0004_auth_v0_2.sql +0 -0
  207. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0005_storage_schema.sql +0 -0
  208. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0006_realtime_schema.sql +0 -0
  209. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0007_jobs_schema.sql +0 -0
  210. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0008_jobs_last_error.sql +0 -0
  211. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0009_auth_rate_limits.sql +0 -0
  212. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0010_worker_heartbeat.sql +0 -0
  213. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0011_admin_schema.sql +0 -0
  214. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0012_auth_banned_until.sql +0 -0
  215. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0013_email_templates.sql +0 -0
  216. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0014_realtime_payload_warning.sql +0 -0
  217. {supython-0.1.12 → supython-0.1.14}/src/supython/migrations/0015_backups_schema.sql +0 -0
  218. {supython-0.1.12 → supython-0.1.14}/src/supython/passwords.py +0 -0
  219. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/__init__.py +0 -0
  220. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/broker.py +0 -0
  221. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/protocol.py +0 -0
  222. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/router.py +0 -0
  223. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/schemas.py +0 -0
  224. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/service.py +0 -0
  225. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/topics.py +0 -0
  226. {supython-0.1.12 → supython-0.1.14}/src/supython/realtime/websocket.py +0 -0
  227. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/__init__.py +0 -0
  228. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/init_project.py +0 -0
  229. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/Caddyfile.tmpl +0 -0
  230. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/README.md.tmpl +0 -0
  231. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/apps_hooks_welcome.py.tmpl +0 -0
  232. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/apps_jobs_example.py.tmpl +0 -0
  233. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/asgi.py.tmpl +0 -0
  234. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker-compose.prod.yml.tmpl +0 -0
  235. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker-compose.yml.tmpl +0 -0
  236. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +0 -0
  237. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +0 -0
  238. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/env.example.tmpl +0 -0
  239. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/functions_README.md.tmpl +0 -0
  240. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/gitignore.tmpl +0 -0
  241. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/manage.py.tmpl +0 -0
  242. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_hooks_init.py.tmpl +0 -0
  243. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_init.py.tmpl +0 -0
  244. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/package_jobs_init.py.tmpl +0 -0
  245. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/pyproject.toml.tmpl +0 -0
  246. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/settings.py.tmpl +0 -0
  247. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/static/0001_create_todos.sql +0 -0
  248. {supython-0.1.12 → supython-0.1.14}/src/supython/scaffold/templates/static/functions_hello.py +0 -0
  249. {supython-0.1.12 → supython-0.1.14}/src/supython/secretset.py +0 -0
  250. {supython-0.1.12 → supython-0.1.14}/src/supython/security_headers.py +0 -0
  251. {supython-0.1.12 → supython-0.1.14}/src/supython/settings_module.py +0 -0
  252. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/__init__.py +0 -0
  253. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/backends.py +0 -0
  254. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/router.py +0 -0
  255. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/schemas.py +0 -0
  256. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/service.py +0 -0
  257. {supython-0.1.12 → supython-0.1.14}/src/supython/storage/signing.py +0 -0
  258. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supython
3
- Version: 0.1.12
3
+ Version: 0.1.14
4
4
  Summary: A lightweight Postgres-first BaaS framework for Python
5
5
  Project-URL: Homepage, https://github.com/Tkeby/supython
6
6
  Project-URL: Repository, https://github.com/Tkeby/supython
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "supython"
7
- version = "0.1.12"
7
+ version = "0.1.14"
8
8
  description = "A lightweight Postgres-first BaaS framework for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,8 +2,18 @@ from typing import Annotated
2
2
  from uuid import UUID
3
3
 
4
4
  import jwt
5
- from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
6
- from fastapi.responses import RedirectResponse
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
- async with db.acquire() as conn:
202
- await service.request_magic_link(conn, payload.email)
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
- ) -> TokenResponse:
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
- result = await service.verify_magic_link(conn, token)
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
- return _to_token_response(*result)
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, email: str
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(conn, row["id"], "magic_link", s.magic_link_token_ttl)
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
- return user, access, refresh, ttl
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, *args: str, profiles: tuple[str, ...] = ()
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