regstack 0.8.1__tar.gz → 0.8.3__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.8.1 → regstack-0.8.3}/CHANGELOG.md +55 -0
- {regstack-0.8.1 → regstack-0.8.3}/PKG-INFO +1 -1
- {regstack-0.8.1 → regstack-0.8.3}/pyproject.toml +4 -1
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/clock.py +13 -4
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +2 -1
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/protocols.py +5 -0
- regstack-0.8.3/src/regstack/backends/sql/migrations/versions/0003_oauth_state_result_was_new.py +44 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_state_repo.py +3 -1
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/schema.py +1 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_results.py +9 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/doctor.py +119 -4
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/schema.py +9 -5
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/oauth_state.py +7 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/providers/google.py +33 -3
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/oauth.py +4 -2
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/null.py +6 -5
- regstack-0.8.3/src/regstack/version.py +1 -0
- regstack-0.8.1/src/regstack/version.py +0 -1
- {regstack-0.8.1 → regstack-0.8.3}/.gitignore +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/LICENSE +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/NOTICE +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/README.md +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/SECURITY.md +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/regstack.toml.example +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/app.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/lockout.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/password.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/rate_limit.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/base.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/factory.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/backend.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/indexes.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/backend.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/__main__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_paths.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/admin.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/init.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/migrate.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/capture.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/cli.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/http.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/logtail.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/account.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/cleanup.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/core_auth.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/oauth.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/password_reset.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/reachability.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/report.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/runner.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/loader.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/secrets.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/base.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/composer.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/console.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/factory.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/ses.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/smtp.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.txt +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/hooks/events.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/oauth_identity.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/user.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/base.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/errors.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/providers/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/registry.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/_helpers.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/account.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/admin.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/login.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/logout.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/password.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/phone.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/register.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/verify.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/base.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/factory.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/sns.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/pages.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/js/regstack.js +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/_token_handoff.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/login.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/me.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/reset.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/verify.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/cli.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/routes.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/server.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/validators.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/window.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/writer.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/_aws.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/cli.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/routes.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/server.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.css +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.js +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/templates/wizard.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/validators.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/window.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/writer.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/__init__.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/cli.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/routes.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/server.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/validators.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/window.py +0 -0
- {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/writer.py +0 -0
|
@@ -7,6 +7,61 @@ Sphinx docs.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.8.3 — 2026-06-12
|
|
11
|
+
|
|
12
|
+
Closes out the 2026-05-15 → 2026-05-22 daily security-review series,
|
|
13
|
+
fixes `was_new_account` on the OAuth exchange (new migration `0003`),
|
|
14
|
+
and stops the test suite leaking MongoDB databases.
|
|
15
|
+
|
|
16
|
+
**Fixed: Google JWKS fetch now has a timeout.** `PyJWKClient` was
|
|
17
|
+
constructed without a `timeout`, so the synchronous `urllib` fetch
|
|
18
|
+
(offloaded to `asyncio.to_thread`) could pin a worker thread
|
|
19
|
+
indefinitely during a Google JWKS outage and, under sustained load,
|
|
20
|
+
exhaust the bounded asyncio thread pool. Added a 5-second
|
|
21
|
+
`JWKS_FETCH_TIMEOUT_SECONDS`. (Daily security review 2026-05-22 · W-1.)
|
|
22
|
+
|
|
23
|
+
**Fixed: Google OAuth token-exchange error no longer logs the full
|
|
24
|
+
provider response body at WARNING.** On a non-200 token exchange the
|
|
25
|
+
provider's response body is now logged at DEBUG; the raised
|
|
26
|
+
`OAuthTokenExchangeError` (which the router logs at WARNING) carries only
|
|
27
|
+
`HTTP <status>`. (Daily security review 2026-05-22 · I-3.)
|
|
28
|
+
|
|
29
|
+
**Fixed: `/oauth/exchange` now reports `was_new_account` accurately.**
|
|
30
|
+
The field was hardcoded `False`; the callback computed whether it created
|
|
31
|
+
a brand-new account but had nowhere to persist it. Added
|
|
32
|
+
`oauth_states.result_was_new` (migration `0003`) which the callback sets
|
|
33
|
+
and the exchange endpoint reads. (Daily security review 2026-05-22 · I-1.)
|
|
34
|
+
|
|
35
|
+
**Documented: `phone_number` exposure in the admin user listing.**
|
|
36
|
+
`docs/security.md` now spells out that `UserPublic.phone_number` is
|
|
37
|
+
returned in plaintext on `GET /admin/users` (regulated PII in some
|
|
38
|
+
jurisdictions) and that hosts wanting to mask/omit it should wrap the
|
|
39
|
+
admin listing in their own response model. (Daily security review
|
|
40
|
+
2026-05-21 · I-2.)
|
|
41
|
+
|
|
42
|
+
**Fixed: `FrozenClock` now defaults to the far future (2125-01-01).**
|
|
43
|
+
MongoDB's TTL monitor uses real wall-clock time, so the old 2025-01-01
|
|
44
|
+
default meant every TTL-indexed test row was born already-expired and
|
|
45
|
+
could be reaped mid-test when a ~60s TTL sweep landed — a rare,
|
|
46
|
+
mongo-only parallel flake. A far-future pin keeps the reaper out of
|
|
47
|
+
reach while staying deterministic.
|
|
48
|
+
|
|
49
|
+
**Fixed: test runs no longer leak MongoDB databases.** Tests using the
|
|
50
|
+
`make_client` factory bypassed the only fixture that dropped the
|
|
51
|
+
per-test database, leaking one `regstack_test_*` DB per test per run.
|
|
52
|
+
A teardown fixture wired into `config` now drops the DB on every path,
|
|
53
|
+
a run-token-scoped `pytest_sessionfinish` sweep catches anything a
|
|
54
|
+
crashed worker leaves behind, and `inv clean-test-dbs` purges leftovers
|
|
55
|
+
from hard-killed runs.
|
|
56
|
+
|
|
57
|
+
**Triaged: CVE-2026-2978 / CVE-2026-2979 not applicable.** Flagged for
|
|
58
|
+
monitoring in the 2026-05-22 review; published NVD details show both
|
|
59
|
+
affect the third-party "FastApiAdmin" project, not the `fastapi`
|
|
60
|
+
library. regstack has no `fastapi-admin` dependency and no fastapi-core
|
|
61
|
+
advisories affect versions ≥ 0.120.0 (our floor). Triage note recorded
|
|
62
|
+
beside the floor in `pyproject.toml`. (Daily security review
|
|
63
|
+
2026-05-22 · I-2.)
|
|
64
|
+
|
|
10
65
|
## 0.8.0 — 2026-05-19
|
|
11
66
|
|
|
12
67
|
`regstack ses setup` guided wizard, plus two security fixes from
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regstack
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "regstack"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.3"
|
|
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"
|
|
@@ -19,6 +19,9 @@ classifiers = [
|
|
|
19
19
|
dependencies = [
|
|
20
20
|
# fastapi>=0.120.0 picks up CVE-2025-62727 (Starlette DoS via large
|
|
21
21
|
# request bodies after multipart processing).
|
|
22
|
+
# CVE-2026-2978 / CVE-2026-2979 (flagged for monitoring in the
|
|
23
|
+
# 2026-05-22 security review, I-2) affect the third-party
|
|
24
|
+
# "FastApiAdmin" project, not fastapi itself — no floor bump needed.
|
|
22
25
|
"fastapi>=0.120.0",
|
|
23
26
|
"pydantic>=2.6",
|
|
24
27
|
"pydantic-settings>=2.2",
|
|
@@ -42,14 +42,23 @@ class FrozenClock:
|
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
44
|
def __init__(self, start: datetime | None = None) -> None:
|
|
45
|
-
"""Pin the clock at ``start`` (default
|
|
45
|
+
"""Pin the clock at ``start`` (default 2125-01-01 UTC).
|
|
46
|
+
|
|
47
|
+
The default is deliberately ~100 years in the *future*. MongoDB's
|
|
48
|
+
TTL monitor deletes documents by comparing their ``expires_at``
|
|
49
|
+
against real wall-clock time on a ~60s cycle — it knows nothing
|
|
50
|
+
about this injected clock. A frozen "now" in the past would give
|
|
51
|
+
every TTL-indexed test row (oauth_states, pending_registrations,
|
|
52
|
+
mfa_codes, login_attempts, blacklist) an already-elapsed
|
|
53
|
+
``expires_at``, and any test straddling a TTL sweep would have
|
|
54
|
+
its rows reaped mid-flight. A far-future pin keeps the reaper
|
|
55
|
+
permanently out of reach while staying deterministic.
|
|
46
56
|
|
|
47
57
|
Args:
|
|
48
58
|
start: The initial timestamp. Should be tz-aware. Defaults
|
|
49
|
-
to ``
|
|
50
|
-
memorable.
|
|
59
|
+
to ``2125-01-01T00:00:00Z``.
|
|
51
60
|
"""
|
|
52
|
-
self._now = start or datetime(
|
|
61
|
+
self._now = start or datetime(2125, 1, 1, tzinfo=UTC)
|
|
53
62
|
|
|
54
63
|
def now(self) -> datetime:
|
|
55
64
|
"""Return the currently-pinned timestamp."""
|
{regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_state_repo.py
RENAMED
|
@@ -31,8 +31,9 @@ class MongoOAuthStateRepo:
|
|
|
31
31
|
token: str,
|
|
32
32
|
*,
|
|
33
33
|
new_expires_at: datetime | None = None,
|
|
34
|
+
was_new: bool = False,
|
|
34
35
|
) -> None:
|
|
35
|
-
updates: dict[str, Any] = {"result_token": token}
|
|
36
|
+
updates: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
|
|
36
37
|
if new_expires_at is not None:
|
|
37
38
|
updates["expires_at"] = new_expires_at
|
|
38
39
|
await self._collection.update_one(
|
|
@@ -295,6 +295,7 @@ class OAuthStateRepoProtocol(Protocol):
|
|
|
295
295
|
token: str,
|
|
296
296
|
*,
|
|
297
297
|
new_expires_at: datetime | None = None,
|
|
298
|
+
was_new: bool = False,
|
|
298
299
|
) -> None:
|
|
299
300
|
"""Stash the session JWT after a successful callback so the
|
|
300
301
|
SPA can pick it up via :meth:`consume`.
|
|
@@ -305,6 +306,10 @@ class OAuthStateRepoProtocol(Protocol):
|
|
|
305
306
|
``oauth.state_ttl_seconds`` (covering the round-trip with
|
|
306
307
|
the provider) to ``oauth.completion_ttl_seconds`` (covering
|
|
307
308
|
only the SPA's exchange call after the callback lands).
|
|
309
|
+
|
|
310
|
+
``was_new`` records whether the callback created a brand-new
|
|
311
|
+
account, so the exchange response can report
|
|
312
|
+
``was_new_account`` accurately.
|
|
308
313
|
"""
|
|
309
314
|
...
|
|
310
315
|
|
regstack-0.8.3/src/regstack/backends/sql/migrations/versions/0003_oauth_state_result_was_new.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Add oauth_states.result_was_new.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0003
|
|
4
|
+
Revises: 0002
|
|
5
|
+
Create Date: 2026-06-04
|
|
6
|
+
|
|
7
|
+
The OAuth callback computes whether it created a brand-new account
|
|
8
|
+
(vs. signing an existing one in) but had nowhere to persist it, so the
|
|
9
|
+
``POST /oauth/exchange`` response always reported
|
|
10
|
+
``was_new_account=False``. This adds the column the callback writes and
|
|
11
|
+
the exchange endpoint reads (Security review 2026-05-22 · I-1).
|
|
12
|
+
|
|
13
|
+
The column is NOT NULL with a ``False`` server default so the in-flight
|
|
14
|
+
state rows that exist at migration time (if any) get a sensible value
|
|
15
|
+
without a backfill. ``batch_alter_table`` keeps the ADD COLUMN safe on
|
|
16
|
+
SQLite.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sqlalchemy as sa
|
|
22
|
+
from alembic import op
|
|
23
|
+
|
|
24
|
+
revision: str = "0003"
|
|
25
|
+
down_revision: str | None = "0002"
|
|
26
|
+
branch_labels: tuple[str, ...] | None = None
|
|
27
|
+
depends_on: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def upgrade() -> None:
|
|
31
|
+
with op.batch_alter_table("oauth_states") as batch_op:
|
|
32
|
+
batch_op.add_column(
|
|
33
|
+
sa.Column(
|
|
34
|
+
"result_was_new",
|
|
35
|
+
sa.Boolean(),
|
|
36
|
+
nullable=False,
|
|
37
|
+
server_default=sa.false(),
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def downgrade() -> None:
|
|
43
|
+
with op.batch_alter_table("oauth_states") as batch_op:
|
|
44
|
+
batch_op.drop_column("result_was_new")
|
{regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_state_repo.py
RENAMED
|
@@ -34,6 +34,7 @@ class SqlOAuthStateRepo:
|
|
|
34
34
|
"created_at": state.created_at,
|
|
35
35
|
"expires_at": state.expires_at,
|
|
36
36
|
"result_token": state.result_token,
|
|
37
|
+
"result_was_new": state.result_was_new,
|
|
37
38
|
}
|
|
38
39
|
async with self._engine.begin() as conn:
|
|
39
40
|
await conn.execute(self._t.insert().values(values))
|
|
@@ -50,8 +51,9 @@ class SqlOAuthStateRepo:
|
|
|
50
51
|
token: str,
|
|
51
52
|
*,
|
|
52
53
|
new_expires_at: datetime | None = None,
|
|
54
|
+
was_new: bool = False,
|
|
53
55
|
) -> None:
|
|
54
|
-
values: dict[str, Any] = {"result_token": token}
|
|
56
|
+
values: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
|
|
55
57
|
if new_expires_at is not None:
|
|
56
58
|
values["expires_at"] = new_expires_at
|
|
57
59
|
stmt = update(self._t).where(self._t.c.id == state_id).values(**values)
|
|
@@ -162,6 +162,7 @@ def _oauth_states(table_name: str) -> Table:
|
|
|
162
162
|
Column("created_at", UtcDateTime(), nullable=False),
|
|
163
163
|
Column("expires_at", UtcDateTime(), nullable=False),
|
|
164
164
|
Column("result_token", Text, nullable=True),
|
|
165
|
+
Column("result_was_new", Boolean, nullable=False, default=False),
|
|
165
166
|
Index("ix_oauth_states_expires_at", "expires_at"),
|
|
166
167
|
CheckConstraint("mode IN ('signin', 'link')", name="mode_valid"),
|
|
167
168
|
)
|
|
@@ -15,6 +15,7 @@ class CheckResult:
|
|
|
15
15
|
ok: bool
|
|
16
16
|
detail: str
|
|
17
17
|
skipped: bool = False
|
|
18
|
+
warn: bool = False
|
|
18
19
|
|
|
19
20
|
@classmethod
|
|
20
21
|
def passed(cls, name: str, detail: str) -> CheckResult:
|
|
@@ -27,3 +28,11 @@ class CheckResult:
|
|
|
27
28
|
@classmethod
|
|
28
29
|
def skip(cls, name: str, detail: str) -> CheckResult:
|
|
29
30
|
return cls(name=name, ok=True, detail=detail, skipped=True)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def warned(cls, name: str, detail: str) -> CheckResult:
|
|
34
|
+
"""An advisory finding: not a hard failure (``ok=True`` so it
|
|
35
|
+
doesn't fail the command), but surfaced distinctly so operators
|
|
36
|
+
notice it. Used for things outside regstack's control that the
|
|
37
|
+
operator should act on — e.g. an out-of-date database server."""
|
|
38
|
+
return cls(name=name, ok=True, detail=detail, warn=True)
|
|
@@ -49,14 +49,23 @@ def doctor(config_path_in: Path | None, check_dns: bool, test_recipient: str | N
|
|
|
49
49
|
_run(toml_path=toml_path, check_dns=check_dns, test_recipient=test_recipient)
|
|
50
50
|
)
|
|
51
51
|
failed = sum(1 for r in results if not r.ok)
|
|
52
|
+
warned = sum(1 for r in results if r.warn)
|
|
52
53
|
for r in results:
|
|
53
|
-
|
|
54
|
+
if r.warn:
|
|
55
|
+
symbol = click.style("⚠", fg="yellow")
|
|
56
|
+
elif r.ok:
|
|
57
|
+
symbol = click.style("✔", fg="green")
|
|
58
|
+
else:
|
|
59
|
+
symbol = click.style("✘", fg="red")
|
|
54
60
|
click.echo(f"{symbol} {r.name}: {r.detail}")
|
|
61
|
+
if warned:
|
|
62
|
+
click.echo(click.style(f"\n{warned} advisory warning(s).", fg="yellow"), err=True)
|
|
55
63
|
if failed:
|
|
56
|
-
click.echo(click.style(f"
|
|
64
|
+
click.echo(click.style(f"{failed} check(s) failed.", fg="red"), err=True)
|
|
57
65
|
# Clamp to 0/1 so a shell `regstack doctor && deploy` is predictable;
|
|
58
|
-
#
|
|
59
|
-
#
|
|
66
|
+
# advisory warnings (e.g. an out-of-date DB server) do NOT fail the
|
|
67
|
+
# command — they're surfaced but exit 0. The failure count appears on
|
|
68
|
+
# the stderr line above for operators who want it. (Review #4.)
|
|
60
69
|
sys.exit(1 if failed else 0)
|
|
61
70
|
|
|
62
71
|
|
|
@@ -79,6 +88,9 @@ async def _run(
|
|
|
79
88
|
|
|
80
89
|
out.append(await _check_backend(config))
|
|
81
90
|
out.append(await _check_schema(config))
|
|
91
|
+
mongo_version = await _check_mongo_server_version(config)
|
|
92
|
+
if mongo_version is not None:
|
|
93
|
+
out.append(mongo_version)
|
|
82
94
|
out.append(_check_email_factory(config))
|
|
83
95
|
|
|
84
96
|
if check_dns:
|
|
@@ -156,6 +168,109 @@ async def _check_schema(config: RegStackConfig) -> CheckResult:
|
|
|
156
168
|
await backend.aclose()
|
|
157
169
|
|
|
158
170
|
|
|
171
|
+
# CVE-2025-14847 ("MongoBleed", CVSS 8.7): an unauthenticated network
|
|
172
|
+
# attacker can leak server memory via crafted zlib-compressed messages.
|
|
173
|
+
# The pymongo driver is not the vulnerable component — the server binary
|
|
174
|
+
# is. Patched server releases by LTS track. Keyed (major, minor) →
|
|
175
|
+
# minimum safe (major, minor, patch). See docs/security-reports/2026-05-20.md.
|
|
176
|
+
_MONGO_PATCHED_BASELINE: dict[tuple[int, int], tuple[int, int, int]] = {
|
|
177
|
+
(8, 2): (8, 2, 3),
|
|
178
|
+
(8, 0): (8, 0, 17),
|
|
179
|
+
(7, 0): (7, 0, 28),
|
|
180
|
+
(6, 0): (6, 0, 27),
|
|
181
|
+
(5, 0): (5, 0, 32),
|
|
182
|
+
(4, 4): (4, 4, 30),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# The newest LTS track we have a baseline for. A server on a track newer
|
|
186
|
+
# than this (e.g. 8.3+, 9.x) post-dates the advisory and is treated as safe.
|
|
187
|
+
_MONGO_NEWEST_KNOWN_TRACK = max(_MONGO_PATCHED_BASELINE)
|
|
188
|
+
# The oldest LTS track the advisory lists. Anything below it (3.6, 4.0,
|
|
189
|
+
# 4.2, …) is EOL and below every patched release — warn.
|
|
190
|
+
_MONGO_OLDEST_KNOWN_TRACK = min(_MONGO_PATCHED_BASELINE)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_mongo_version(version: str) -> tuple[int, int, int] | None:
|
|
194
|
+
"""Parse a MongoDB version string like ``"7.0.5"`` into ``(7, 0, 5)``.
|
|
195
|
+
|
|
196
|
+
Tolerates a release-candidate / pre-release suffix (``"8.0.0-rc1"``)
|
|
197
|
+
by splitting on the first ``-``. Returns ``None`` if the leading
|
|
198
|
+
three dotted components aren't all integers.
|
|
199
|
+
"""
|
|
200
|
+
core = version.split("-", 1)[0]
|
|
201
|
+
parts = core.split(".")
|
|
202
|
+
if len(parts) < 3:
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
|
206
|
+
except ValueError:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _assess_mongo_server_version(version: str) -> tuple[bool, str]:
|
|
211
|
+
"""Decide whether ``version`` is safe against CVE-2025-14847.
|
|
212
|
+
|
|
213
|
+
Returns ``(ok, detail)``. ``ok`` is False only when the server is on
|
|
214
|
+
a known-affected LTS track *below* its patched baseline — that's the
|
|
215
|
+
case worth a WARNING. Newer-than-advisory and patched servers return
|
|
216
|
+
True; an unrecognised/older track returns True with a "verify
|
|
217
|
+
manually" note rather than crying wolf on every odd build string.
|
|
218
|
+
"""
|
|
219
|
+
parsed = _parse_mongo_version(version)
|
|
220
|
+
if parsed is None:
|
|
221
|
+
return True, f"server {version} (could not parse version; verify against CVE-2025-14847)"
|
|
222
|
+
track = (parsed[0], parsed[1])
|
|
223
|
+
baseline = _MONGO_PATCHED_BASELINE.get(track)
|
|
224
|
+
if baseline is not None:
|
|
225
|
+
if parsed < baseline:
|
|
226
|
+
patched = ".".join(str(n) for n in baseline)
|
|
227
|
+
return False, (
|
|
228
|
+
f"server {version} is vulnerable to CVE-2025-14847 (MongoBleed); "
|
|
229
|
+
f"upgrade to ≥ {patched}, or disable zlib compression in mongod.conf"
|
|
230
|
+
)
|
|
231
|
+
return True, f"server {version} (≥ {'.'.join(str(n) for n in baseline)}, patched)"
|
|
232
|
+
if track > _MONGO_NEWEST_KNOWN_TRACK:
|
|
233
|
+
return True, f"server {version} (newer than CVE-2025-14847 advisory tracks)"
|
|
234
|
+
if track < _MONGO_OLDEST_KNOWN_TRACK:
|
|
235
|
+
return False, (
|
|
236
|
+
f"server {version} is end-of-life and below every CVE-2025-14847 "
|
|
237
|
+
"patched release; upgrade to a supported, patched MongoDB version"
|
|
238
|
+
)
|
|
239
|
+
return True, (
|
|
240
|
+
f"server {version} on an unrecognised release track; verify against CVE-2025-14847 manually"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _check_mongo_server_version(config: RegStackConfig) -> CheckResult | None:
|
|
245
|
+
"""Advisory check: warn if the MongoDB *server* is below the
|
|
246
|
+
CVE-2025-14847 patched baseline. Returns None for non-Mongo backends
|
|
247
|
+
(the check doesn't apply).
|
|
248
|
+
"""
|
|
249
|
+
from regstack.backends.base import BackendKind
|
|
250
|
+
|
|
251
|
+
backend = build_backend(config)
|
|
252
|
+
try:
|
|
253
|
+
if backend.kind is not BackendKind.MONGO:
|
|
254
|
+
return None
|
|
255
|
+
from regstack.backends.mongo import MongoBackend
|
|
256
|
+
|
|
257
|
+
assert isinstance(backend, MongoBackend)
|
|
258
|
+
info = await backend.database.command("buildInfo")
|
|
259
|
+
version = str(info.get("version", ""))
|
|
260
|
+
if not version:
|
|
261
|
+
return CheckResult.warned(
|
|
262
|
+
"mongo server", "buildInfo returned no version; verify against CVE-2025-14847"
|
|
263
|
+
)
|
|
264
|
+
ok, detail = _assess_mongo_server_version(version)
|
|
265
|
+
if ok:
|
|
266
|
+
return CheckResult.passed("mongo server", detail)
|
|
267
|
+
return CheckResult.warned("mongo server", detail)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
return CheckResult.warned("mongo server", f"version check failed: {exc}")
|
|
270
|
+
finally:
|
|
271
|
+
await backend.aclose()
|
|
272
|
+
|
|
273
|
+
|
|
159
274
|
def _check_email_factory(config: RegStackConfig) -> CheckResult:
|
|
160
275
|
try:
|
|
161
276
|
service = build_email_service(config.email)
|
|
@@ -98,11 +98,15 @@ class SmsConfig(BaseModel):
|
|
|
98
98
|
twilio_account_sid: str | None = None
|
|
99
99
|
twilio_auth_token: SecretStr | None = None
|
|
100
100
|
|
|
101
|
-
log_bodies: bool =
|
|
102
|
-
"""When True
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
log_bodies: bool = False
|
|
102
|
+
"""When True, the ``null`` backend logs the SMS body (including the
|
|
103
|
+
6-digit MFA code) at INFO. Defaults to False so a misconfigured
|
|
104
|
+
deployment can't leak codes into shared logs — symmetric with
|
|
105
|
+
``email.log_bodies``. Set it True for local dev when you want to
|
|
106
|
+
read the code out of stdout (the bundled examples instead surface
|
|
107
|
+
it via an ``mfa_login_started`` hook, so they don't need this).
|
|
108
|
+
Other backends ignore this flag — they never log message bodies.
|
|
109
|
+
(Security review 2026-05-19.)"""
|
|
106
110
|
|
|
107
111
|
|
|
108
112
|
class OAuthConfig(BaseModel):
|
|
@@ -77,6 +77,13 @@ class OAuthState(BaseModel):
|
|
|
77
77
|
``id`` for this value via ``POST /oauth/exchange``; the row is
|
|
78
78
|
deleted on exchange."""
|
|
79
79
|
|
|
80
|
+
result_was_new: bool = False
|
|
81
|
+
"""Whether the callback created a brand-new account (vs. signing in
|
|
82
|
+
an existing one). Set alongside ``result_token`` so the
|
|
83
|
+
``POST /oauth/exchange`` response can surface ``was_new_account``
|
|
84
|
+
accurately — the callback computes this but had no field to persist
|
|
85
|
+
it on before (Security review 2026-05-22 · I-1)."""
|
|
86
|
+
|
|
80
87
|
def to_mongo(self) -> dict[str, Any]:
|
|
81
88
|
data = self.model_dump(by_alias=True, exclude_none=True)
|
|
82
89
|
return data
|
|
@@ -16,6 +16,8 @@ installed and turns ``enable_oauth`` on.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
19
21
|
from typing import TYPE_CHECKING, Any
|
|
20
22
|
from urllib.parse import urlencode
|
|
21
23
|
|
|
@@ -29,6 +31,8 @@ from regstack.oauth.errors import OAuthIdTokenError, OAuthTokenExchangeError
|
|
|
29
31
|
if TYPE_CHECKING:
|
|
30
32
|
from collections.abc import Iterable
|
|
31
33
|
|
|
34
|
+
log = logging.getLogger("regstack.oauth.google")
|
|
35
|
+
|
|
32
36
|
GOOGLE_ISSUER = "https://accounts.google.com"
|
|
33
37
|
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
34
38
|
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
@@ -36,6 +40,13 @@ GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
|
|
|
36
40
|
|
|
37
41
|
DEFAULT_SCOPES: tuple[str, ...] = ("openid", "email", "profile")
|
|
38
42
|
|
|
43
|
+
# Bound the synchronous JWKS fetch so a slow or unreachable Google JWKS
|
|
44
|
+
# endpoint can't pin a worker thread indefinitely. The fetch is offloaded
|
|
45
|
+
# to `asyncio.to_thread`, but `urllib` defaults to no timeout — under a
|
|
46
|
+
# sustained JWKS outage that would let cache-miss requests exhaust the
|
|
47
|
+
# bounded asyncio thread pool. (Security review 2026-05-22 · W-1.)
|
|
48
|
+
JWKS_FETCH_TIMEOUT_SECONDS = 5
|
|
49
|
+
|
|
39
50
|
|
|
40
51
|
class GoogleProvider(OAuthProvider):
|
|
41
52
|
"""OIDC provider for Google.
|
|
@@ -79,7 +90,9 @@ class GoogleProvider(OAuthProvider):
|
|
|
79
90
|
self._owns_http = http is None
|
|
80
91
|
self._issuer = issuer
|
|
81
92
|
self._scopes = tuple(scopes)
|
|
82
|
-
self._jwks_client = PyJWKClient(
|
|
93
|
+
self._jwks_client = PyJWKClient(
|
|
94
|
+
jwks_url, cache_keys=True, timeout=JWKS_FETCH_TIMEOUT_SECONDS
|
|
95
|
+
)
|
|
83
96
|
|
|
84
97
|
@property
|
|
85
98
|
def name(self) -> str:
|
|
@@ -140,8 +153,18 @@ class GoogleProvider(OAuthProvider):
|
|
|
140
153
|
if self._owns_http and client is not self._http:
|
|
141
154
|
await client.aclose()
|
|
142
155
|
if response.status_code != 200:
|
|
156
|
+
# Keep the provider's response body at DEBUG only. It doesn't
|
|
157
|
+
# carry our client secret or the auth code, but Google's error
|
|
158
|
+
# bodies are verbose and there's no reason to put them in
|
|
159
|
+
# production WARNING logs — the status code is the actionable
|
|
160
|
+
# signal. (Security review 2026-05-22 · I-3.)
|
|
161
|
+
log.debug(
|
|
162
|
+
"google token exchange response body (HTTP %s): %s",
|
|
163
|
+
response.status_code,
|
|
164
|
+
response.text,
|
|
165
|
+
)
|
|
143
166
|
raise OAuthTokenExchangeError(
|
|
144
|
-
f"google token exchange failed: {response.status_code}
|
|
167
|
+
f"google token exchange failed: HTTP {response.status_code}"
|
|
145
168
|
)
|
|
146
169
|
body: dict[str, Any] = response.json()
|
|
147
170
|
try:
|
|
@@ -162,7 +185,14 @@ class GoogleProvider(OAuthProvider):
|
|
|
162
185
|
expected_nonce: str,
|
|
163
186
|
) -> OAuthUserInfo:
|
|
164
187
|
try:
|
|
165
|
-
|
|
188
|
+
# PyJWKClient's fetch is synchronous urllib; on a cache miss it
|
|
189
|
+
# would block the event loop for the round-trip to Google's JWKS
|
|
190
|
+
# endpoint. Push it to a worker thread so concurrent requests
|
|
191
|
+
# aren't stalled. (Security review 2026-05-20 · I-2.)
|
|
192
|
+
signing_key_obj = await asyncio.to_thread(
|
|
193
|
+
self._jwks_client.get_signing_key_from_jwt, id_token
|
|
194
|
+
)
|
|
195
|
+
signing_key = signing_key_obj.key
|
|
166
196
|
except Exception as exc: # PyJWKClient raises a grab-bag — collapse to ours
|
|
167
197
|
raise OAuthIdTokenError(f"jwks lookup failed: {exc}") from exc
|
|
168
198
|
try:
|
|
@@ -168,7 +168,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
|
|
|
168
168
|
if _is_mfa_pending_token(state.result_token):
|
|
169
169
|
return ExchangeResponse(
|
|
170
170
|
redirect_to=state.redirect_to,
|
|
171
|
-
was_new_account=
|
|
171
|
+
was_new_account=state.result_was_new,
|
|
172
172
|
expires_in=rs.config.mfa_pending_token_ttl_seconds,
|
|
173
173
|
mfa_required=True,
|
|
174
174
|
mfa_pending_token=state.result_token,
|
|
@@ -176,7 +176,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
|
|
|
176
176
|
return ExchangeResponse(
|
|
177
177
|
access_token=state.result_token,
|
|
178
178
|
redirect_to=state.redirect_to,
|
|
179
|
-
was_new_account=
|
|
179
|
+
was_new_account=state.result_was_new,
|
|
180
180
|
expires_in=rs.config.jwt_ttl_seconds,
|
|
181
181
|
)
|
|
182
182
|
|
|
@@ -361,6 +361,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
|
|
|
361
361
|
pending_token,
|
|
362
362
|
new_expires_at=rs.clock.now()
|
|
363
363
|
+ timedelta(seconds=rs.config.mfa_pending_token_ttl_seconds),
|
|
364
|
+
was_new=was_new,
|
|
364
365
|
)
|
|
365
366
|
else:
|
|
366
367
|
# Mint the session JWT, stash it on the state row for the
|
|
@@ -376,6 +377,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
|
|
|
376
377
|
token,
|
|
377
378
|
new_expires_at=rs.clock.now()
|
|
378
379
|
+ timedelta(seconds=rs.config.oauth.completion_ttl_seconds),
|
|
380
|
+
was_new=was_new,
|
|
379
381
|
)
|
|
380
382
|
|
|
381
383
|
await rs.hooks.fire(
|
|
@@ -10,14 +10,15 @@ log = logging.getLogger("regstack.sms.null")
|
|
|
10
10
|
class NullSmsService(SmsService):
|
|
11
11
|
"""Default backend. Records messages in ``self.outbox`` so tests and dev
|
|
12
12
|
runs can inspect them without contacting a real SMS gateway. Logs each
|
|
13
|
-
send at INFO
|
|
13
|
+
send at INFO.
|
|
14
14
|
|
|
15
|
-
``log_bodies`` (default
|
|
16
|
-
(containing the 6-digit code) is included in the log line.
|
|
17
|
-
|
|
15
|
+
``log_bodies`` (default False) controls whether the message body
|
|
16
|
+
(containing the 6-digit code) is included in the log line. It defaults
|
|
17
|
+
off so a misconfigured deployment can't leak codes into shared logs;
|
|
18
|
+
flip it on for local dev when you want to read the code out of stdout.
|
|
18
19
|
"""
|
|
19
20
|
|
|
20
|
-
def __init__(self, *, log_bodies: bool =
|
|
21
|
+
def __init__(self, *, log_bodies: bool = False) -> None:
|
|
21
22
|
self.outbox: list[SmsMessage] = []
|
|
22
23
|
self._log_bodies = log_bodies
|
|
23
24
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.3"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/blacklist_repo.py
RENAMED
|
File without changes
|
{regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/login_attempt_repo.py
RENAMED
|
File without changes
|
|
File without changes
|
{regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|