regstack 0.2.5__tar.gz → 0.3.0__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.
- {regstack-0.2.5 → regstack-0.3.0}/CHANGELOG.md +47 -0
- {regstack-0.2.5 → regstack-0.3.0}/PKG-INFO +4 -1
- {regstack-0.2.5 → regstack-0.3.0}/docs/changelog.md +113 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/index.md +1 -0
- regstack-0.3.0/docs/oauth.md +135 -0
- {regstack-0.2.5 → regstack-0.3.0}/pyproject.toml +4 -1
- regstack-0.3.0/src/regstack/__init__.py +12 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/app.py +29 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/base.py +4 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/backend.py +6 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/indexes.py +27 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/__init__.py +6 -0
- regstack-0.3.0/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +63 -0
- regstack-0.3.0/src/regstack/backends/mongo/repositories/oauth_state_repo.py +45 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/pending_repo.py +4 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/protocols.py +105 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/backend.py +6 -0
- regstack-0.3.0/src/regstack/backends/sql/migrations/versions/0002_oauth.py +91 -0
- regstack-0.3.0/src/regstack/backends/sql/repositories/oauth_identity_repo.py +87 -0
- regstack-0.3.0/src/regstack/backends/sql/repositories/oauth_state_repo.py +66 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/pending_repo.py +9 -1
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/schema.py +47 -1
- regstack-0.3.0/src/regstack/config/__init__.py +4 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/schema.py +37 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/hooks/events.py +4 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/__init__.py +5 -0
- regstack-0.3.0/src/regstack/models/oauth_identity.py +55 -0
- regstack-0.3.0/src/regstack/models/oauth_state.py +82 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/user.py +5 -1
- regstack-0.3.0/src/regstack/oauth/__init__.py +42 -0
- regstack-0.3.0/src/regstack/oauth/base.py +188 -0
- regstack-0.3.0/src/regstack/oauth/errors.py +43 -0
- regstack-0.3.0/src/regstack/oauth/providers/__init__.py +6 -0
- regstack-0.3.0/src/regstack/oauth/providers/google.py +200 -0
- regstack-0.3.0/src/regstack/oauth/registry.py +74 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/__init__.py +7 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/account.py +28 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/admin.py +1 -7
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/login.py +7 -1
- regstack-0.3.0/src/regstack/routers/oauth.py +536 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/pages.py +12 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/js/regstack.js +133 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/login.html +14 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/me.html +8 -0
- regstack-0.3.0/src/regstack/ui/templates/auth/oauth_complete.html +14 -0
- regstack-0.3.0/src/regstack/version.py +1 -0
- regstack-0.3.0/tasks/oauth-design.md +729 -0
- regstack-0.3.0/tests/_fake_google/__init__.py +14 -0
- regstack-0.3.0/tests/_fake_google/provider.py +166 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_admin_router.py +56 -0
- regstack-0.3.0/tests/integration/test_oauth_google_router.py +583 -0
- regstack-0.3.0/tests/integration/test_oauth_repos.py +267 -0
- regstack-0.3.0/tests/integration/test_oauth_ui.py +168 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_base_install_imports.py +22 -12
- regstack-0.3.0/tests/unit/test_oauth_google.py +445 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_ui_env.py +1 -0
- {regstack-0.2.5 → regstack-0.3.0}/uv.lock +72 -2
- regstack-0.2.5/src/regstack/__init__.py +0 -5
- regstack-0.2.5/src/regstack/config/__init__.py +0 -4
- regstack-0.2.5/src/regstack/version.py +0 -1
- {regstack-0.2.5 → regstack-0.3.0}/.github/workflows/publish.yml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/.github/workflows/test.yml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/.gitignore +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/.python-version +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/.readthedocs.yaml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/CLAUDE.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/LICENSE +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/NOTICE +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/README.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/SECURITY.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/_static/.gitkeep +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/_templates/.gitkeep +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/api.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/architecture.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/cli.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/conf.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/configuration.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/embedding.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/quickstart.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/security.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/docs/theming.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/_common/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/_common/app.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/README.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/branding/theme.css +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/main.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/mongo/regstack.toml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/README.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/main.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/postgres/regstack.toml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/README.md +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/main.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/examples/sqlite/regstack.toml +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/regstack.toml.example +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/clock.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/lockout.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/password.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/factory.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/__main__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/admin.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/doctor.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/init.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/cli/migrate.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/loader.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/config/secrets.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/base.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/composer.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/console.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/factory.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/ses.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/smtp.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/email/templates/verification.txt +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/logout.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/password.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/phone.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/register.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/routers/verify.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/base.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/factory.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/null.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/sns.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/reset.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/auth/verify.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tasks.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/conftest.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_account_management.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_happy_path.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_indexes.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_login_lockout.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_mfa.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_password_reset.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_sql_migrations.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_ui_router.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/integration/test_verification.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/__init__.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli_doctor.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_cli_init.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_config_loader.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_jwt.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_lockout.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_mail_composer.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_mfa_code_repo.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_password.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_ses_backend.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_sms.py +0 -0
- {regstack-0.2.5 → regstack-0.3.0}/tests/unit/test_smtp_backend.py +0 -0
|
@@ -5,6 +5,53 @@ authoritative copy lives at
|
|
|
5
5
|
[`docs/changelog.md`](docs/changelog.md) and is rendered into the
|
|
6
6
|
Sphinx docs.
|
|
7
7
|
|
|
8
|
+
## 0.3.0 — 2026-04-30
|
|
9
|
+
|
|
10
|
+
**OAuth — Sign in with Google.** Opt-in via the new `oauth` extra
|
|
11
|
+
and `enable_oauth=True`. Five JSON endpoints, an SSR
|
|
12
|
+
`/account/oauth-complete` page, "Sign in with Google" button on the
|
|
13
|
+
login page, and a Connected-accounts panel on `/account/me`.
|
|
14
|
+
|
|
15
|
+
Schema migration `0002_oauth.py` creates `oauth_identities` +
|
|
16
|
+
`oauth_states` and makes `users.hashed_password` nullable
|
|
17
|
+
(OAuth-only users have no password). Roll forward via
|
|
18
|
+
`regstack migrate` or first-boot `install_schema()` — no manual
|
|
19
|
+
intervention.
|
|
20
|
+
|
|
21
|
+
Account-linking policy defaults to **refuse**: if a Google sign-in
|
|
22
|
+
arrives carrying an email that already belongs to a password-
|
|
23
|
+
registered user, the callback returns `?error=email_in_use` and the
|
|
24
|
+
user must sign in then explicitly link from `/account/me`. Hosts
|
|
25
|
+
that consciously accept the email-recycling threat for UX can flip
|
|
26
|
+
`oauth.auto_link_verified_emails = true`. See
|
|
27
|
+
[`docs/oauth.md`](https://regstack.readthedocs.io/en/latest/oauth.html)
|
|
28
|
+
and [`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md)
|
|
29
|
+
for the full threat model.
|
|
30
|
+
|
|
31
|
+
**Migration**
|
|
32
|
+
|
|
33
|
+
- Install the new extra: `uv add 'regstack[oauth]'`.
|
|
34
|
+
- Set `enable_oauth = true` and provide `oauth.google_client_id` +
|
|
35
|
+
`oauth.google_client_secret`.
|
|
36
|
+
- Run `regstack migrate` (SQL backends only) or rely on
|
|
37
|
+
`install_schema()` at first boot.
|
|
38
|
+
|
|
39
|
+
`BaseUser.hashed_password` is now `str | None`. Code that imported
|
|
40
|
+
the field type explicitly will need to widen it.
|
|
41
|
+
|
|
42
|
+
## 0.2.6 — 2026-04-28
|
|
43
|
+
|
|
44
|
+
Bug fix.
|
|
45
|
+
|
|
46
|
+
- **Fix:** `/admin/stats` reported `pending_registrations: 0` on
|
|
47
|
+
every SQL backend. The route reached into the Mongo repo's private
|
|
48
|
+
`_collection` attribute and silently fell back to `0` when the
|
|
49
|
+
attribute was absent. Added `count_unexpired(now=None)` to
|
|
50
|
+
`PendingRepoProtocol` with Mongo + SQL implementations and routed
|
|
51
|
+
through `rs.clock.now()` so the count respects the injected clock.
|
|
52
|
+
New parametrized integration test exercises the count on every
|
|
53
|
+
backend.
|
|
54
|
+
|
|
8
55
|
## 0.2.5 — 2026-04-28
|
|
9
56
|
|
|
10
57
|
Bug fix + tooling.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jdrumgoole/regstack
|
|
6
6
|
Project-URL: Repository, https://github.com/jdrumgoole/regstack
|
|
@@ -36,6 +36,7 @@ Requires-Dist: asyncpg>=0.29; extra == 'dev'
|
|
|
36
36
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
37
37
|
Requires-Dist: invoke>=2.2; extra == 'dev'
|
|
38
38
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
39
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'dev'
|
|
39
40
|
Requires-Dist: pymongo>=4.9; extra == 'dev'
|
|
40
41
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
41
42
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
@@ -52,6 +53,8 @@ Requires-Dist: sphinx-copybutton>=0.5; extra == 'docs'
|
|
|
52
53
|
Requires-Dist: sphinx>=7.3; extra == 'docs'
|
|
53
54
|
Provides-Extra: mongo
|
|
54
55
|
Requires-Dist: pymongo>=4.9; extra == 'mongo'
|
|
56
|
+
Provides-Extra: oauth
|
|
57
|
+
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'oauth'
|
|
55
58
|
Provides-Extra: postgres
|
|
56
59
|
Requires-Dist: asyncpg>=0.29; extra == 'postgres'
|
|
57
60
|
Provides-Extra: ses
|
|
@@ -3,6 +3,119 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Versions follow
|
|
4
4
|
[Semantic Versioning](https://semver.org/) once `1.0.0` ships.
|
|
5
5
|
|
|
6
|
+
## 0.3.0 — 2026-04-30
|
|
7
|
+
|
|
8
|
+
**OAuth — Sign in with Google.** Built across four PRs (M1–M4 of
|
|
9
|
+
[`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md));
|
|
10
|
+
this is the release cut that wraps them up.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- New optional extra `oauth = ["pyjwt[crypto]>=2.8"]`.
|
|
15
|
+
- New `enable_oauth` flag and `OAuthConfig` sub-model
|
|
16
|
+
(`google_client_id`, `google_client_secret`, `google_redirect_uri`,
|
|
17
|
+
`auto_link_verified_emails`, `enforce_mfa_on_oauth_signin`,
|
|
18
|
+
`state_ttl_seconds`, `completion_ttl_seconds`).
|
|
19
|
+
- `regstack.oauth` package — `OAuthProvider` ABC, `OAuthRegistry`,
|
|
20
|
+
`OAuthTokens`, `OAuthUserInfo`, error hierarchy, and the concrete
|
|
21
|
+
`GoogleProvider` (Authorization Code with PKCE, ID-token
|
|
22
|
+
verification via `pyjwt[crypto]` + `PyJWKClient` against Google's
|
|
23
|
+
JWKS).
|
|
24
|
+
- Five JSON endpoints (mounted lazily when `enable_oauth=True` and a
|
|
25
|
+
provider is registered):
|
|
26
|
+
- `GET /oauth/{provider}/start`
|
|
27
|
+
- `GET /oauth/{provider}/callback`
|
|
28
|
+
- `POST /oauth/exchange`
|
|
29
|
+
- `POST /oauth/{provider}/link/start` (auth)
|
|
30
|
+
- `DELETE /oauth/{provider}/link` (auth)
|
|
31
|
+
- `GET /oauth/providers` (auth)
|
|
32
|
+
- New SSR page `/account/oauth-complete` (token-handoff round-trip).
|
|
33
|
+
- "Sign in with Google" button on `/account/login` and a Connected-
|
|
34
|
+
accounts panel on `/account/me`. Login page surfaces callback
|
|
35
|
+
errors via `?error=<code>` with translated banners.
|
|
36
|
+
- Two new repo protocols: `OAuthIdentityRepoProtocol`,
|
|
37
|
+
`OAuthStateRepoProtocol`. Mongo + SQL implementations with
|
|
38
|
+
parametrized integration tests over all three backends.
|
|
39
|
+
- Four new hook events: `oauth_signin_started`,
|
|
40
|
+
`oauth_signin_completed`, `oauth_account_linked`,
|
|
41
|
+
`oauth_account_unlinked`.
|
|
42
|
+
- `tests/_fake_google/` — in-process provider stub so the OAuth
|
|
43
|
+
test suite stays offline and parallel-safe.
|
|
44
|
+
- New docs page [`docs/oauth.md`](oauth.md) — host guide.
|
|
45
|
+
|
|
46
|
+
### Changed (potentially breaking)
|
|
47
|
+
|
|
48
|
+
- **`BaseUser.hashed_password: str` → `str | None`.** OAuth-only
|
|
49
|
+
users have no password. The login route rejects password attempts
|
|
50
|
+
on these accounts with the same generic 401 wrong-password gets
|
|
51
|
+
(no enumeration). `change-password`, `change-email`, and
|
|
52
|
+
`delete-account` all return 400 for OAuth-only users with a
|
|
53
|
+
pointer at the password-reset flow, which doubles as a "set
|
|
54
|
+
initial password" path.
|
|
55
|
+
- `users.hashed_password` is now nullable in the SQL schema —
|
|
56
|
+
migration `0002_oauth.py` flips the column via `batch_alter_table`
|
|
57
|
+
(SQLite-safe). Existing rows are unaffected.
|
|
58
|
+
- New SQL tables `oauth_identities` and `oauth_states`. Mongo
|
|
59
|
+
collections + indexes added by `install_schema()`.
|
|
60
|
+
|
|
61
|
+
### Security defaults
|
|
62
|
+
|
|
63
|
+
- **Account-linking policy defaults to refuse.** When a Google
|
|
64
|
+
sign-in carries an email that already belongs to a regstack user,
|
|
65
|
+
the callback returns `?error=email_in_use`. Hosts can opt into
|
|
66
|
+
auto-linking via `oauth.auto_link_verified_emails = true`, which
|
|
67
|
+
also requires `email_verified=true` on the ID token. The threat
|
|
68
|
+
model is in `tasks/oauth-design.md` § 1.
|
|
69
|
+
- **Server-side PKCE.** `code_verifier` is stored on the
|
|
70
|
+
`oauth_states` row and never enters the URL.
|
|
71
|
+
- **One-time token-handoff.** `/oauth/exchange` consumes the state
|
|
72
|
+
row atomically; second exchange returns 404.
|
|
73
|
+
- **Refuse to unlink the only sign-in method.** Returns 400 for
|
|
74
|
+
OAuth-only users attempting to unlink their only provider.
|
|
75
|
+
- **OAuth sessions are normal session JWTs** — the existing
|
|
76
|
+
`tokens_invalidated_after` bulk-revoke applies, so a password
|
|
77
|
+
change kills any OAuth-issued session too.
|
|
78
|
+
|
|
79
|
+
### Migration notes
|
|
80
|
+
|
|
81
|
+
- Install the extra: `uv add 'regstack[oauth]'`.
|
|
82
|
+
- Configure: set `enable_oauth = true` and provide
|
|
83
|
+
`oauth.google_client_id` + `oauth.google_client_secret` (the
|
|
84
|
+
secret in `regstack.secrets.env` as
|
|
85
|
+
`REGSTACK_OAUTH__GOOGLE_CLIENT_SECRET`).
|
|
86
|
+
- Schema: roll forward via `regstack migrate` or rely on
|
|
87
|
+
`install_schema()` at first boot.
|
|
88
|
+
|
|
89
|
+
## 0.2.6 — 2026-04-28
|
|
90
|
+
|
|
91
|
+
**Bug fix.**
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- ``/admin/stats`` reported ``pending_registrations: 0`` on every SQL
|
|
96
|
+
backend. The route reached into the Mongo repo's private
|
|
97
|
+
``_collection`` attribute and silently fell back to ``0`` when the
|
|
98
|
+
attribute was absent — the kind of failure that survives a
|
|
99
|
+
multi-backend refactor when the integration tests don't pin the
|
|
100
|
+
number.
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
|
|
104
|
+
- ``PendingRepoProtocol.count_unexpired(now=None) -> int``, with Mongo
|
|
105
|
+
and SQL implementations. "Unexpired" rather than a raw row count
|
|
106
|
+
because SQL backends accumulate dead rows until ``purge_expired``
|
|
107
|
+
runs; an admin looking at "pending: 47" wants 47 *live* rows.
|
|
108
|
+
- The admin stats route now routes the count through
|
|
109
|
+
``rs.clock.now()``. Without this, ``FrozenClock``-driven tests
|
|
110
|
+
would see every row as "expired" because the route would be reading
|
|
111
|
+
wall-clock time while the rest of the system runs on the injected
|
|
112
|
+
clock. Same shape of clock-injection drift the bulk-revoke fix
|
|
113
|
+
closed earlier.
|
|
114
|
+
- New parametrized integration test
|
|
115
|
+
``test_stats_pending_registrations_count_unexpired`` runs against
|
|
116
|
+
SQLite + Mongo + Postgres and confirms the count excludes expired
|
|
117
|
+
rows on every backend.
|
|
118
|
+
|
|
6
119
|
## 0.2.5 — 2026-04-28
|
|
7
120
|
|
|
8
121
|
**Bug fix + tooling.**
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# OAuth (Sign in with Google)
|
|
2
|
+
|
|
3
|
+
regstack ships an opt-in OAuth subsystem. v1 supports Google; the
|
|
4
|
+
abstraction is shaped so adding GitHub / Microsoft / Apple later is a
|
|
5
|
+
new module under `regstack/oauth/providers/` plus one config field.
|
|
6
|
+
|
|
7
|
+
This page walks a host through enabling it. The full design — including
|
|
8
|
+
the threat model and the four-milestone build sequence the
|
|
9
|
+
implementation followed — is in
|
|
10
|
+
[`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md).
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
When OAuth is enabled and at least one provider is configured:
|
|
15
|
+
|
|
16
|
+
- **Five JSON endpoints** under `/api/auth/oauth/`:
|
|
17
|
+
- `GET /oauth/{provider}/start` — public; redirects to the provider.
|
|
18
|
+
- `GET /oauth/{provider}/callback` — public; handles the redirect back.
|
|
19
|
+
- `POST /oauth/exchange` — single-use; SPA trades the state-id for a
|
|
20
|
+
session JWT.
|
|
21
|
+
- `POST /oauth/{provider}/link/start` — authenticated; returns the URL
|
|
22
|
+
to navigate the browser to.
|
|
23
|
+
- `DELETE /oauth/{provider}/link` — authenticated; unlinks one identity.
|
|
24
|
+
- `GET /oauth/providers` — authenticated; lists configured + linked
|
|
25
|
+
providers (drives the SSR connected-accounts panel).
|
|
26
|
+
- **A "Sign in with Google" button** on the bundled SSR login page.
|
|
27
|
+
- **A "Connected accounts" panel** on the SSR `/account/me` page.
|
|
28
|
+
- **Four hook events**: `oauth_signin_started`, `oauth_signin_completed`,
|
|
29
|
+
`oauth_account_linked`, `oauth_account_unlinked`.
|
|
30
|
+
|
|
31
|
+
## Install the extra
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add 'regstack[oauth]'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The `oauth` extra pulls in `pyjwt[crypto]>=2.8`, which transitively
|
|
38
|
+
includes `cryptography`. ID-token signature verification needs RSA, so
|
|
39
|
+
this is unavoidable.
|
|
40
|
+
|
|
41
|
+
## Register a Google client
|
|
42
|
+
|
|
43
|
+
In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
|
|
44
|
+
|
|
45
|
+
1. Create an **OAuth 2.0 Client ID** of type **Web application**.
|
|
46
|
+
2. Add an **Authorized redirect URI** that exactly matches the URL
|
|
47
|
+
regstack will receive callbacks at — by default that's
|
|
48
|
+
`<your base_url><api_prefix>/oauth/google/callback`. For a local
|
|
49
|
+
dev server with the defaults that's
|
|
50
|
+
`http://localhost:8000/api/auth/oauth/google/callback`.
|
|
51
|
+
3. Copy the **client ID** and **client secret** out — you'll set them
|
|
52
|
+
on regstack next.
|
|
53
|
+
|
|
54
|
+
## Configure regstack
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
# regstack.toml
|
|
58
|
+
enable_oauth = true
|
|
59
|
+
|
|
60
|
+
[oauth]
|
|
61
|
+
google_client_id = "12345.apps.googleusercontent.com"
|
|
62
|
+
# google_client_secret lives in regstack.secrets.env
|
|
63
|
+
# google_redirect_uri = "https://your.app/api/auth/oauth/google/callback" # optional override
|
|
64
|
+
auto_link_verified_emails = false # security choice — see below
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# regstack.secrets.env
|
|
69
|
+
REGSTACK_OAUTH__GOOGLE_CLIENT_SECRET=...
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The router is mounted only when `enable_oauth=true` AND
|
|
73
|
+
`google_client_id` AND `google_client_secret` are all set.
|
|
74
|
+
|
|
75
|
+
## The account-linking decision
|
|
76
|
+
|
|
77
|
+
When a Google sign-in arrives carrying an email that already belongs to
|
|
78
|
+
a regstack user (created via password registration), regstack has to
|
|
79
|
+
choose between three policies:
|
|
80
|
+
|
|
81
|
+
| Policy | Behaviour |
|
|
82
|
+
|---|---|
|
|
83
|
+
| **Refuse** (default) | Return `?error=email_in_use` on the redirect. The user must sign in with their existing password, then link Google from `/account/me`. |
|
|
84
|
+
| **Auto-link verified** | If Google's `email_verified=true`, silently link the new identity to the existing user. UX win, but trusts Google's email-verified claim *forever*. |
|
|
85
|
+
| **Always create new** | Make a second account. |
|
|
86
|
+
|
|
87
|
+
regstack defaults to **refuse**. To opt into auto-linking — accepting
|
|
88
|
+
that an attacker who later acquires a recycled Gmail address could sign
|
|
89
|
+
in as the original regstack user — set
|
|
90
|
+
`oauth.auto_link_verified_emails = true`.
|
|
91
|
+
|
|
92
|
+
The full threat-model writeup is in
|
|
93
|
+
[`tasks/oauth-design.md`](https://github.com/jdrumgoole/regstack/blob/main/tasks/oauth-design.md).
|
|
94
|
+
|
|
95
|
+
## OAuth-only users
|
|
96
|
+
|
|
97
|
+
A Google sign-up creates a regstack user with `hashed_password = None`.
|
|
98
|
+
Three knock-on effects, all handled:
|
|
99
|
+
|
|
100
|
+
- **Login route** rejects password attempts on these accounts with the
|
|
101
|
+
same generic 401 a wrong-password attempt gets — never reveal that
|
|
102
|
+
an account exists but has no password.
|
|
103
|
+
- **`change-password` / `change-email` / `delete-account`** all need
|
|
104
|
+
the current password. For OAuth-only users they return 400 with a
|
|
105
|
+
pointer at the password-reset flow, which doubles as a "set initial
|
|
106
|
+
password" path.
|
|
107
|
+
- **`DELETE /oauth/{provider}/link`** refuses if it would remove the
|
|
108
|
+
user's only sign-in method (no password set, no other linked
|
|
109
|
+
provider). The error is `400 last sign-in method`.
|
|
110
|
+
|
|
111
|
+
## Hooks
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
@regstack.on("oauth_signin_completed")
|
|
115
|
+
async def _track_signin(*, user, provider, mode, was_new):
|
|
116
|
+
if was_new:
|
|
117
|
+
await analytics.track("signup", {"user": user.id, "provider": provider})
|
|
118
|
+
else:
|
|
119
|
+
await analytics.track("login", {"user": user.id, "provider": provider})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@regstack.on("oauth_account_linked")
|
|
123
|
+
async def _notify_link(*, user, provider):
|
|
124
|
+
await mailer.send_link_notification(to=user.email, provider=provider)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The full event list is in the
|
|
128
|
+
[architecture guide](architecture.md#hooks).
|
|
129
|
+
|
|
130
|
+
## Disabling OAuth
|
|
131
|
+
|
|
132
|
+
Flip `enable_oauth = false` (or leave the credentials unset). The
|
|
133
|
+
router won't mount; the SSR login page won't render the button; the
|
|
134
|
+
`/me` panel hides the section. No other configuration changes are
|
|
135
|
+
required.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "regstack"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -40,6 +40,7 @@ mongo = ["pymongo>=4.9"]
|
|
|
40
40
|
ses = ["aioboto3>=12.3"]
|
|
41
41
|
sns = ["aioboto3>=12.3"]
|
|
42
42
|
twilio = ["twilio>=9.0"]
|
|
43
|
+
oauth = ["pyjwt[crypto]>=2.8"]
|
|
43
44
|
docs = [
|
|
44
45
|
"sphinx>=7.3",
|
|
45
46
|
"myst-parser>=3.0",
|
|
@@ -62,6 +63,8 @@ dev = [
|
|
|
62
63
|
# All backend drivers in dev so the parametrized test suite can run.
|
|
63
64
|
"pymongo>=4.9",
|
|
64
65
|
"asyncpg>=0.29",
|
|
66
|
+
# OAuth provider tests need the crypto bits to verify ID tokens.
|
|
67
|
+
"pyjwt[crypto]>=2.8",
|
|
65
68
|
]
|
|
66
69
|
|
|
67
70
|
[project.scripts]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from regstack.app import RegStack
|
|
2
|
+
from regstack.config.schema import EmailConfig, OAuthConfig, RegStackConfig, SmsConfig
|
|
3
|
+
from regstack.version import __version__
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"EmailConfig",
|
|
7
|
+
"OAuthConfig",
|
|
8
|
+
"RegStack",
|
|
9
|
+
"RegStackConfig",
|
|
10
|
+
"SmsConfig",
|
|
11
|
+
"__version__",
|
|
12
|
+
]
|
|
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
|
|
29
29
|
from jinja2 import Environment
|
|
30
30
|
|
|
31
31
|
from regstack.backends.base import Backend
|
|
32
|
+
from regstack.oauth import OAuthRegistry
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class RegStack:
|
|
@@ -131,6 +132,8 @@ class RegStack:
|
|
|
131
132
|
self.blacklist = self.backend.blacklist
|
|
132
133
|
self.attempts = self.backend.attempts
|
|
133
134
|
self.mfa_codes = self.backend.mfa_codes
|
|
135
|
+
self.oauth_identities = self.backend.oauth_identities
|
|
136
|
+
self.oauth_states = self.backend.oauth_states
|
|
134
137
|
|
|
135
138
|
self.lockout = LockoutService(attempts=self.attempts, config=config, clock=self.clock)
|
|
136
139
|
self.email: EmailService = email_service or build_email_service(config.email)
|
|
@@ -141,12 +144,38 @@ class RegStack:
|
|
|
141
144
|
)
|
|
142
145
|
self.hooks = HookRegistry()
|
|
143
146
|
self.deps = AuthDependencies(jwt=self.jwt, users=self.users, blacklist=self.blacklist)
|
|
147
|
+
self.oauth = self._build_oauth_registry()
|
|
144
148
|
self._template_dirs: list[Path] = list(config.extra_template_dirs)
|
|
145
149
|
self._ui_env: Environment | None = None
|
|
146
150
|
self._router: APIRouter | None = None
|
|
147
151
|
self._ui_router: APIRouter | None = None
|
|
148
152
|
self._static_files: StaticFiles | None = None
|
|
149
153
|
|
|
154
|
+
def _build_oauth_registry(self) -> OAuthRegistry:
|
|
155
|
+
"""Build the OAuth registry, populated from config.
|
|
156
|
+
|
|
157
|
+
The ``regstack.oauth`` import is lazy so the package keeps
|
|
158
|
+
importing on a base install (no ``oauth`` extra). When
|
|
159
|
+
``enable_oauth`` is off the registry is empty; the router won't
|
|
160
|
+
be mounted regardless.
|
|
161
|
+
"""
|
|
162
|
+
from regstack.oauth import OAuthRegistry
|
|
163
|
+
|
|
164
|
+
registry = OAuthRegistry()
|
|
165
|
+
if not self.config.enable_oauth:
|
|
166
|
+
return registry
|
|
167
|
+
oauth_cfg = self.config.oauth
|
|
168
|
+
if oauth_cfg.google_client_id and oauth_cfg.google_client_secret:
|
|
169
|
+
from regstack.oauth.providers.google import GoogleProvider
|
|
170
|
+
|
|
171
|
+
registry.register(
|
|
172
|
+
GoogleProvider(
|
|
173
|
+
client_id=oauth_cfg.google_client_id,
|
|
174
|
+
client_secret=oauth_cfg.google_client_secret.get_secret_value(),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return registry
|
|
178
|
+
|
|
150
179
|
@property
|
|
151
180
|
def router(self) -> APIRouter:
|
|
152
181
|
"""The composite JSON ``APIRouter``.
|
|
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
|
|
|
20
20
|
BlacklistRepoProtocol,
|
|
21
21
|
LoginAttemptRepoProtocol,
|
|
22
22
|
MfaCodeRepoProtocol,
|
|
23
|
+
OAuthIdentityRepoProtocol,
|
|
24
|
+
OAuthStateRepoProtocol,
|
|
23
25
|
PendingRepoProtocol,
|
|
24
26
|
UserRepoProtocol,
|
|
25
27
|
)
|
|
@@ -51,6 +53,8 @@ class Backend(ABC):
|
|
|
51
53
|
blacklist: BlacklistRepoProtocol
|
|
52
54
|
attempts: LoginAttemptRepoProtocol
|
|
53
55
|
mfa_codes: MfaCodeRepoProtocol
|
|
56
|
+
oauth_identities: OAuthIdentityRepoProtocol
|
|
57
|
+
oauth_states: OAuthStateRepoProtocol
|
|
54
58
|
|
|
55
59
|
# --- Lifecycle -------------------------------------------------------
|
|
56
60
|
|
|
@@ -8,6 +8,10 @@ from regstack.backends.mongo.indexes import install_indexes
|
|
|
8
8
|
from regstack.backends.mongo.repositories.blacklist_repo import BlacklistRepo
|
|
9
9
|
from regstack.backends.mongo.repositories.login_attempt_repo import LoginAttemptRepo
|
|
10
10
|
from regstack.backends.mongo.repositories.mfa_code_repo import MfaCodeRepo
|
|
11
|
+
from regstack.backends.mongo.repositories.oauth_identity_repo import (
|
|
12
|
+
MongoOAuthIdentityRepo,
|
|
13
|
+
)
|
|
14
|
+
from regstack.backends.mongo.repositories.oauth_state_repo import MongoOAuthStateRepo
|
|
11
15
|
from regstack.backends.mongo.repositories.pending_repo import PendingRepo
|
|
12
16
|
from regstack.backends.mongo.repositories.user_repo import UserRepo
|
|
13
17
|
|
|
@@ -36,6 +40,8 @@ class MongoBackend(Backend):
|
|
|
36
40
|
self.blacklist = BlacklistRepo(self._db, config.blacklist_collection)
|
|
37
41
|
self.attempts = LoginAttemptRepo(self._db, config.login_attempt_collection)
|
|
38
42
|
self.mfa_codes = MfaCodeRepo(self._db, config.mfa_code_collection, clock=clock)
|
|
43
|
+
self.oauth_identities = MongoOAuthIdentityRepo(self._db, config.oauth_identity_collection)
|
|
44
|
+
self.oauth_states = MongoOAuthStateRepo(self._db, config.oauth_state_collection)
|
|
39
45
|
|
|
40
46
|
async def install_schema(self) -> None:
|
|
41
47
|
await install_indexes(self._db, self.config)
|
|
@@ -67,4 +67,31 @@ async def install_indexes(db: AsyncDatabase, config: RegStackConfig) -> None:
|
|
|
67
67
|
]
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
+
oauth_identities = db[config.oauth_identity_collection]
|
|
71
|
+
await oauth_identities.create_indexes(
|
|
72
|
+
[
|
|
73
|
+
IndexModel(
|
|
74
|
+
[("provider", ASCENDING), ("subject_id", ASCENDING)],
|
|
75
|
+
unique=True,
|
|
76
|
+
name="provider_subject_unique",
|
|
77
|
+
),
|
|
78
|
+
IndexModel(
|
|
79
|
+
[("user_id", ASCENDING), ("provider", ASCENDING)],
|
|
80
|
+
unique=True,
|
|
81
|
+
name="user_provider_unique",
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
oauth_states = db[config.oauth_state_collection]
|
|
87
|
+
await oauth_states.create_indexes(
|
|
88
|
+
[
|
|
89
|
+
IndexModel(
|
|
90
|
+
[("expires_at", ASCENDING)],
|
|
91
|
+
expireAfterSeconds=0,
|
|
92
|
+
name="oauth_state_ttl",
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
|
|
70
97
|
log.info("regstack indexes installed on database %s", db.name)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
from regstack.backends.mongo.repositories.blacklist_repo import BlacklistRepo
|
|
2
2
|
from regstack.backends.mongo.repositories.login_attempt_repo import LoginAttemptRepo
|
|
3
3
|
from regstack.backends.mongo.repositories.mfa_code_repo import MfaCodeRepo
|
|
4
|
+
from regstack.backends.mongo.repositories.oauth_identity_repo import (
|
|
5
|
+
MongoOAuthIdentityRepo,
|
|
6
|
+
)
|
|
7
|
+
from regstack.backends.mongo.repositories.oauth_state_repo import MongoOAuthStateRepo
|
|
4
8
|
from regstack.backends.mongo.repositories.pending_repo import PendingRepo
|
|
5
9
|
from regstack.backends.mongo.repositories.user_repo import UserRepo
|
|
6
10
|
|
|
@@ -8,6 +12,8 @@ __all__ = [
|
|
|
8
12
|
"BlacklistRepo",
|
|
9
13
|
"LoginAttemptRepo",
|
|
10
14
|
"MfaCodeRepo",
|
|
15
|
+
"MongoOAuthIdentityRepo",
|
|
16
|
+
"MongoOAuthStateRepo",
|
|
11
17
|
"PendingRepo",
|
|
12
18
|
"UserRepo",
|
|
13
19
|
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from bson import ObjectId
|
|
7
|
+
from pymongo.errors import DuplicateKeyError
|
|
8
|
+
|
|
9
|
+
from regstack.backends.protocols import OAuthIdentityAlreadyLinkedError
|
|
10
|
+
from regstack.models.oauth_identity import OAuthIdentity
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MongoOAuthIdentityRepo:
|
|
17
|
+
def __init__(self, db: AsyncDatabase, collection_name: str) -> None:
|
|
18
|
+
self._collection = db[collection_name]
|
|
19
|
+
|
|
20
|
+
async def create(self, identity: OAuthIdentity) -> OAuthIdentity:
|
|
21
|
+
try:
|
|
22
|
+
result = await self._collection.insert_one(identity.to_mongo())
|
|
23
|
+
except DuplicateKeyError as exc:
|
|
24
|
+
raise OAuthIdentityAlreadyLinkedError(
|
|
25
|
+
f"{identity.provider}/{identity.subject_id}"
|
|
26
|
+
) from exc
|
|
27
|
+
identity.id = str(result.inserted_id)
|
|
28
|
+
return identity
|
|
29
|
+
|
|
30
|
+
async def find_by_subject(self, *, provider: str, subject_id: str) -> OAuthIdentity | None:
|
|
31
|
+
doc = await self._collection.find_one({"provider": provider, "subject_id": subject_id})
|
|
32
|
+
return self._hydrate(doc)
|
|
33
|
+
|
|
34
|
+
async def list_for_user(self, user_id: str) -> list[OAuthIdentity]:
|
|
35
|
+
cursor = self._collection.find({"user_id": user_id}).sort("linked_at", 1)
|
|
36
|
+
out: list[OAuthIdentity] = []
|
|
37
|
+
async for doc in cursor:
|
|
38
|
+
identity = self._hydrate(doc)
|
|
39
|
+
if identity is not None:
|
|
40
|
+
out.append(identity)
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
async def delete(self, *, user_id: str, provider: str) -> bool:
|
|
44
|
+
result = await self._collection.delete_one({"user_id": user_id, "provider": provider})
|
|
45
|
+
return bool(result.deleted_count)
|
|
46
|
+
|
|
47
|
+
async def delete_by_user_id(self, user_id: str) -> int:
|
|
48
|
+
result = await self._collection.delete_many({"user_id": user_id})
|
|
49
|
+
return int(result.deleted_count)
|
|
50
|
+
|
|
51
|
+
async def touch_last_used(self, *, provider: str, subject_id: str, when: datetime) -> None:
|
|
52
|
+
await self._collection.update_one(
|
|
53
|
+
{"provider": provider, "subject_id": subject_id},
|
|
54
|
+
{"$set": {"last_used_at": when}},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _hydrate(doc: dict[str, Any] | None) -> OAuthIdentity | None:
|
|
59
|
+
if doc is None:
|
|
60
|
+
return None
|
|
61
|
+
if isinstance(doc.get("_id"), ObjectId):
|
|
62
|
+
doc["_id"] = str(doc["_id"])
|
|
63
|
+
return OAuthIdentity.model_validate(doc)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from regstack.models.oauth_state import OAuthState
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pymongo.asynchronous.database import AsyncDatabase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MongoOAuthStateRepo:
|
|
13
|
+
def __init__(self, db: AsyncDatabase, collection_name: str) -> None:
|
|
14
|
+
self._collection = db[collection_name]
|
|
15
|
+
|
|
16
|
+
async def create(self, state: OAuthState) -> None:
|
|
17
|
+
# We use the caller-supplied id as _id directly so consume() can
|
|
18
|
+
# delete by primary key without needing a separate index.
|
|
19
|
+
doc = state.to_mongo()
|
|
20
|
+
await self._collection.insert_one(doc)
|
|
21
|
+
|
|
22
|
+
async def find(self, state_id: str) -> OAuthState | None:
|
|
23
|
+
doc = await self._collection.find_one({"_id": state_id})
|
|
24
|
+
return self._hydrate(doc)
|
|
25
|
+
|
|
26
|
+
async def set_result_token(self, state_id: str, token: str) -> None:
|
|
27
|
+
await self._collection.update_one(
|
|
28
|
+
{"_id": state_id},
|
|
29
|
+
{"$set": {"result_token": token}},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def consume(self, state_id: str) -> OAuthState | None:
|
|
33
|
+
doc = await self._collection.find_one_and_delete({"_id": state_id})
|
|
34
|
+
return self._hydrate(doc)
|
|
35
|
+
|
|
36
|
+
async def purge_expired(self, now: datetime | None = None) -> int:
|
|
37
|
+
cutoff = now or datetime.now(UTC)
|
|
38
|
+
result = await self._collection.delete_many({"expires_at": {"$lt": cutoff}})
|
|
39
|
+
return int(result.deleted_count)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _hydrate(doc: dict[str, Any] | None) -> OAuthState | None:
|
|
43
|
+
if doc is None:
|
|
44
|
+
return None
|
|
45
|
+
return OAuthState.model_validate(doc)
|
|
@@ -64,6 +64,10 @@ class PendingRepo:
|
|
|
64
64
|
result = await self._collection.delete_many({"expires_at": {"$lt": cutoff}})
|
|
65
65
|
return int(result.deleted_count)
|
|
66
66
|
|
|
67
|
+
async def count_unexpired(self, now: datetime | None = None) -> int:
|
|
68
|
+
cutoff = now or datetime.now(UTC)
|
|
69
|
+
return await self._collection.count_documents({"expires_at": {"$gt": cutoff}})
|
|
70
|
+
|
|
67
71
|
@staticmethod
|
|
68
72
|
def _hydrate(doc: dict[str, Any] | None) -> PendingRegistration | None:
|
|
69
73
|
if doc is None:
|