fastapi-fullauth 0.17.0__tar.gz → 0.17.2__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.
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/CHANGELOG.md +18 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/PKG-INFO +1 -1
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/configuration.md +5 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/__init__.py +1 -1
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/_sqlalchemy_base.py +128 -72
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/config.py +22 -13
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/blacklist.py +3 -3
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/challenges.py +8 -1
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/lockout.py +1 -1
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/ratelimit.py +16 -2
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/pyproject.toml +6 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/conftest.py +15 -3
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_config.py +48 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_passkey.py +22 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_security.py +56 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sqlmodel_adapter.py +21 -1
- fastapi_fullauth-0.17.2/uv.lock +2772 -0
- fastapi_fullauth-0.17.0/uv.lock +0 -2658
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/pull_request_template.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/ci.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/docs.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.github/workflows/publish.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.gitignore +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/.python-version +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/AGENTS.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/CONTRIBUTING.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/LICENSE +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/Makefile +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/README.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/SECURITY.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/beanie.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/custom.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/index.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/sqlalchemy.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/sqlmodel.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/adapters/tortoise.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/api-reference.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/architecture.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/custom-claims.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/dependencies.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/hooks.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/passwords.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/auth/sessions.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/contributing.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/customization.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/frontend-integration.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/getting-started.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/index.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/migrating-from-fastapi-users.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/migrations.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/oauth.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/passkeys.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/recipes.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/middleware.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/rate-limiting.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/security/threat-model.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/stylesheets/home.css +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/testing.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/docs/troubleshooting.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/config.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/main.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/models.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/beanie_app/routes.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/config.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/main.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/models.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlalchemy_app/routes.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/config.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/main.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/models.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/sqlmodel_app/routes.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/config.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/main.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/models.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/examples/tortoise_app/routes.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/beanie.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/sqlalchemy.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/sqlmodel.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/tortoise.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/bearer.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/backends/cookie.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/cli.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/_redis.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/crypto.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/core/tokens.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/current_user.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/dependencies/rbac.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/exceptions.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/change_password.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/credentials.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/email_verify.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/login.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/logout.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/password_reset.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/profile.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/reauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/refresh.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/register.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/sessions.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/flows/tokens.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/fullauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/hooks.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/csrf.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/ratelimit.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/middleware/security_headers.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/_common.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/permission.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/beanie/role.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/permission.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlalchemy/role.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/permission.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/sqlmodel/role.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/_common.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/permission.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/models/tortoise/role.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/base.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/discord.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/github.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/gitlab.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/oauth/google.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/protection/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/py.typed +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/_schemas.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/_transport.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/admin.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/passkey.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/profile.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/sessions.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/routers/verify.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/types.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/utils.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/validators.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/mkdocs.yml +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/scripts/llmstxt_preprocess.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/SKILL.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/adapters.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/api-reference.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/composable-design.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/getting-started.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/hooks.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/migrations.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/oauth.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/passkeys.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/production.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/rbac.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/sessions.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/testing.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/skills/fastapi-fullauth/references/troubleshooting.md +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/__init__.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/adapter_conformance.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_account_safety.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_auth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_beanie_adapter.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_cli.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_cookie_backend.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_crypto.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_hooks.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_oauth.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_passkey_flows.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_polish.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_profile.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_rbac.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_session_revocation.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sessions.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_sqlalchemy_adapter.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tokens.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tortoise_adapter.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_tortoise_int_keys.py +0 -0
- {fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/tests/test_user_id_types.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.2
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **The Redis challenge store raised on a non-positive TTL** where the in-memory one wrote an entry that was simply already expired. With `PASSKEY_CHALLENGE_TTL` set to `0`, beginning a passkey sign-in answered `500` on Redis, on a route that needs no account. Both backends now agree: nothing is stored and the next `pop()` returns `None`.
|
|
8
|
+
- **Numeric settings reject values that cannot work.** Durations, expiries, attempt counts and rate-limit caps are validated at construction, so a mistake fails at startup instead of somewhere unrelated later: `MAX_LOGIN_ATTEMPTS=0` locked every account on its first failed login, and `PASSKEY_CHALLENGE_TTL=0` raised from Redis mid-request. The three settings that document a meaningful zero keep it: `JWT_LEEWAY_SECONDS`, `PASSWORD_MAX_LENGTH` and `REAUTH_MAX_AGE_SECONDS`. **If your configuration holds one of these impossible values, the application now refuses to start and says which setting.**
|
|
9
|
+
- **Auth rate limits shared one bucket per IP on Redis.** Each route gets its own limiter with its own cap, but the key was the client IP alone, and every Redis-backed limiter writes into the same keyspace, so all six auth routes counted into a single sorted set. The smallest cap won for all of them: with `register` at 3, any three auth requests, a token refresh or a failed login included, returned `429` on the next registration attempt for the rest of the window. It also broke the separation `reauth` was given in 0.17.0, where failed password checks were meant not to touch the sign-in budget. The key now carries the route name. Only Redis was affected; the in-memory backend keeps its counts per limiter instance, which is why the test suite never saw it, and why the new regression test runs against both backends. **Note:** the key format changed, so existing counters reset once on deploy.
|
|
10
|
+
|
|
11
|
+
## 0.17.1
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **The SQL adapters no longer call an API that SQLModel deprecates.** `_BaseSQLAlchemyAdapter` is shared by the SQLAlchemy and SQLModel adapters, and its 40 `session.execute()` calls emitted a `DeprecationWarning` for anyone using SQLModel's `AsyncSession`, which is one of the two setups the documentation offers. Applications that treat warnings as errors failed on it. `exec()` cannot replace it, because a plain SQLAlchemy session does not have one and the base serves both; the calls now go through a helper that invokes SQLAlchemy's own `execute()` directly. SQLModel's override does nothing but delegate to it, so behaviour is identical and results are still `Row` objects.
|
|
16
|
+
- **Redis writes use `set(..., ex=)` instead of `setex`,** which redis-py has deprecated since 2.6.12. Affects the token blacklist, the passkey challenge store, and the lockout manager. Stored values and expiries are unchanged.
|
|
17
|
+
- **The SQLModel adapter contract runs against both documented session classes.** Every test built its sessions with SQLAlchemy's `AsyncSession`, so the SQLModel one had no coverage at all, which is why the deprecation above reached an application instead of CI. The conformance suite now runs a second time with `class_=AsyncSession` from SQLModel.
|
|
18
|
+
- **The test suite treats deprecations as errors,** with no ignore entries. A deprecation that cannot be fixed yet gets its own line naming what it waits for, rather than a blanket suppression.
|
|
19
|
+
- **Dependencies refreshed and verified against the newest releases,** including uuid-utils 1.0, SQLModel 0.0.47, SQLAlchemy 2.0.54, PyJWT 2.15, Starlette 1.7 and pymdown-extensions 12. The declared version floors are unchanged: they state the oldest release the library works with, not the newest it was tested against, and nothing here requires a newer one.
|
|
20
|
+
|
|
3
21
|
## 0.17.0
|
|
4
22
|
|
|
5
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-fullauth
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.2
|
|
4
4
|
Summary: Production-grade, async-native authentication and authorization library for FastAPI
|
|
5
5
|
Project-URL: Changelog, https://github.com/mdfarhankc/fastapi-fullauth/blob/main/CHANGELOG.md
|
|
6
6
|
Project-URL: Documentation, https://mdfarhankc.github.io/fastapi-fullauth
|
|
@@ -124,6 +124,11 @@ If you want to be defensively explicit that no file is ever read, pass `FullAuth
|
|
|
124
124
|
|
|
125
125
|
## Reference
|
|
126
126
|
|
|
127
|
+
Durations, expiries, attempt counts and rate-limit caps must be at least 1, and a
|
|
128
|
+
value that cannot work is rejected when the config is constructed rather than
|
|
129
|
+
failing somewhere unrelated later. `JWT_LEEWAY_SECONDS`, `PASSWORD_MAX_LENGTH`
|
|
130
|
+
and `REAUTH_MAX_AGE_SECONDS` accept `0`, which means something for each of them.
|
|
131
|
+
|
|
127
132
|
### Core
|
|
128
133
|
|
|
129
134
|
| Option | Type | Default | Description |
|
{fastapi_fullauth-0.17.0 → fastapi_fullauth-0.17.2}/fastapi_fullauth/adapters/_sqlalchemy_base.py
RENAMED
|
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
|
|
|
16
16
|
from typing import Any, TypeVar, cast
|
|
17
17
|
from uuid import UUID
|
|
18
18
|
|
|
19
|
-
from sqlalchemy import CursorResult, delete, select, update
|
|
19
|
+
from sqlalchemy import CursorResult, Result, delete, select, update
|
|
20
20
|
from sqlalchemy.exc import IntegrityError
|
|
21
21
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
22
22
|
from sqlalchemy.orm import selectinload
|
|
@@ -258,6 +258,22 @@ class _BaseSQLAlchemyAdapter(
|
|
|
258
258
|
await session.rollback()
|
|
259
259
|
raise
|
|
260
260
|
|
|
261
|
+
@staticmethod
|
|
262
|
+
async def _execute(session: AsyncSession, statement: Any) -> Result[Any]:
|
|
263
|
+
"""Run a statement through SQLAlchemy's own ``execute()``.
|
|
264
|
+
|
|
265
|
+
SQLModel's ``AsyncSession`` deprecates ``execute()`` in favour of
|
|
266
|
+
``exec()``, but ``exec()`` does not exist on a plain SQLAlchemy session
|
|
267
|
+
and this base serves both. Its override only delegates to the parent, so
|
|
268
|
+
calling the parent directly is identical and warns for nobody. Results
|
|
269
|
+
stay ``Row`` objects, hence the ``.scalars()`` calls throughout.
|
|
270
|
+
|
|
271
|
+
SQLAlchemy infers the row type from the statement at the call site; it
|
|
272
|
+
cannot do that through a helper, so a caller that needs the column type
|
|
273
|
+
narrows it itself.
|
|
274
|
+
"""
|
|
275
|
+
return await AsyncSession.execute(session, statement)
|
|
276
|
+
|
|
261
277
|
def _user_query(self) -> Any:
|
|
262
278
|
# Eager-load roles so _to_schema() can read user.roles outside the session
|
|
263
279
|
# without triggering an async refresh.
|
|
@@ -278,7 +294,9 @@ class _BaseSQLAlchemyAdapter(
|
|
|
278
294
|
|
|
279
295
|
async def get_user_by_id(self, user_id: UserID) -> UserSchemaType | None:
|
|
280
296
|
async with self._begin() as session:
|
|
281
|
-
result = await
|
|
297
|
+
result = await self._execute(
|
|
298
|
+
session, self._user_query().where(self._user_model.id == user_id)
|
|
299
|
+
)
|
|
282
300
|
user = result.scalars().first()
|
|
283
301
|
return self._to_schema(user) if user else None
|
|
284
302
|
|
|
@@ -294,7 +312,7 @@ class _BaseSQLAlchemyAdapter(
|
|
|
294
312
|
if field == "email":
|
|
295
313
|
value = normalize_email(value)
|
|
296
314
|
async with self._begin() as session:
|
|
297
|
-
result = await
|
|
315
|
+
result = await self._execute(session, self._user_query().where(column == value))
|
|
298
316
|
user = result.scalars().first()
|
|
299
317
|
return self._to_schema(user) if user else None
|
|
300
318
|
|
|
@@ -316,7 +334,9 @@ class _BaseSQLAlchemyAdapter(
|
|
|
316
334
|
f"User with email {normalized_email} already exists"
|
|
317
335
|
) from e
|
|
318
336
|
# Re-fetch with roles eager-loaded so _to_schema works outside the session.
|
|
319
|
-
result = await
|
|
337
|
+
result = await self._execute(
|
|
338
|
+
session, self._user_query().where(self._user_model.id == user.id)
|
|
339
|
+
)
|
|
320
340
|
user = result.scalars().first()
|
|
321
341
|
assert user is not None
|
|
322
342
|
await self._commit(session)
|
|
@@ -329,11 +349,14 @@ class _BaseSQLAlchemyAdapter(
|
|
|
329
349
|
if data:
|
|
330
350
|
# Skip an empty UPDATE: update(...).values() with no columns is a
|
|
331
351
|
# compile error in SQLAlchemy. An empty update is a no-op anyway.
|
|
332
|
-
await
|
|
333
|
-
|
|
352
|
+
await self._execute(
|
|
353
|
+
session,
|
|
354
|
+
update(self._user_model).where(self._user_model.id == user_id).values(**data),
|
|
334
355
|
)
|
|
335
356
|
await self._commit(session)
|
|
336
|
-
result = await
|
|
357
|
+
result = await self._execute(
|
|
358
|
+
session, self._user_query().where(self._user_model.id == user_id)
|
|
359
|
+
)
|
|
337
360
|
user = result.scalars().first()
|
|
338
361
|
if user is None:
|
|
339
362
|
raise ValueError(f"User {user_id} not found")
|
|
@@ -341,8 +364,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
341
364
|
|
|
342
365
|
async def delete_user(self, user_id: UserID) -> None:
|
|
343
366
|
async with self._begin() as session:
|
|
344
|
-
result = await
|
|
345
|
-
select(self._user_model).where(self._user_model.id == user_id)
|
|
367
|
+
result = await self._execute(
|
|
368
|
+
session, select(self._user_model).where(self._user_model.id == user_id)
|
|
346
369
|
)
|
|
347
370
|
user = result.scalars().first()
|
|
348
371
|
if user:
|
|
@@ -353,7 +376,9 @@ class _BaseSQLAlchemyAdapter(
|
|
|
353
376
|
if not hasattr(self._user_model, "roles"):
|
|
354
377
|
return []
|
|
355
378
|
async with self._begin() as session:
|
|
356
|
-
result = await
|
|
379
|
+
result = await self._execute(
|
|
380
|
+
session, self._user_query().where(self._user_model.id == user_id)
|
|
381
|
+
)
|
|
357
382
|
user = result.scalars().first()
|
|
358
383
|
if user is None:
|
|
359
384
|
return []
|
|
@@ -361,17 +386,19 @@ class _BaseSQLAlchemyAdapter(
|
|
|
361
386
|
|
|
362
387
|
async def get_hashed_password(self, user_id: UserID) -> str | None:
|
|
363
388
|
async with self._begin() as session:
|
|
364
|
-
result = await
|
|
365
|
-
|
|
389
|
+
result = await self._execute(
|
|
390
|
+
session,
|
|
391
|
+
select(self._user_model.hashed_password).where(self._user_model.id == user_id),
|
|
366
392
|
)
|
|
367
|
-
return result.scalars().first()
|
|
393
|
+
return cast("str | None", result.scalars().first())
|
|
368
394
|
|
|
369
395
|
async def set_password(self, user_id: UserID, hashed_password: str) -> None:
|
|
370
396
|
async with self._begin() as session:
|
|
371
|
-
await
|
|
397
|
+
await self._execute(
|
|
398
|
+
session,
|
|
372
399
|
update(self._user_model)
|
|
373
400
|
.where(self._user_model.id == user_id)
|
|
374
|
-
.values(hashed_password=hashed_password)
|
|
401
|
+
.values(hashed_password=hashed_password),
|
|
375
402
|
)
|
|
376
403
|
await self._commit(session)
|
|
377
404
|
|
|
@@ -391,10 +418,11 @@ class _BaseSQLAlchemyAdapter(
|
|
|
391
418
|
|
|
392
419
|
async def get_refresh_token(self, token_str: str) -> RefreshToken | None:
|
|
393
420
|
async with self._begin() as session:
|
|
394
|
-
result = await
|
|
421
|
+
result = await self._execute(
|
|
422
|
+
session,
|
|
395
423
|
select(self._refresh_token_model).where(
|
|
396
424
|
self._refresh_token_model.token == token_str
|
|
397
|
-
)
|
|
425
|
+
),
|
|
398
426
|
)
|
|
399
427
|
row = result.scalars().first()
|
|
400
428
|
if row is None:
|
|
@@ -413,11 +441,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
413
441
|
async with self._begin() as session:
|
|
414
442
|
result = cast(
|
|
415
443
|
"CursorResult[Any]",
|
|
416
|
-
await
|
|
444
|
+
await self._execute(
|
|
445
|
+
session,
|
|
417
446
|
update(self._refresh_token_model)
|
|
418
447
|
.where(self._refresh_token_model.token == token_str)
|
|
419
448
|
.where(self._refresh_token_model.revoked.is_(False))
|
|
420
|
-
.values(revoked=True)
|
|
449
|
+
.values(revoked=True),
|
|
421
450
|
),
|
|
422
451
|
)
|
|
423
452
|
await self._commit(session)
|
|
@@ -425,19 +454,21 @@ class _BaseSQLAlchemyAdapter(
|
|
|
425
454
|
|
|
426
455
|
async def revoke_refresh_token_family(self, family_id: str) -> None:
|
|
427
456
|
async with self._begin() as session:
|
|
428
|
-
await
|
|
457
|
+
await self._execute(
|
|
458
|
+
session,
|
|
429
459
|
update(self._refresh_token_model)
|
|
430
460
|
.where(self._refresh_token_model.family_id == family_id)
|
|
431
|
-
.values(revoked=True)
|
|
461
|
+
.values(revoked=True),
|
|
432
462
|
)
|
|
433
463
|
await self._commit(session)
|
|
434
464
|
|
|
435
465
|
async def revoke_all_user_refresh_tokens(self, user_id: UserID) -> None:
|
|
436
466
|
async with self._begin() as session:
|
|
437
|
-
await
|
|
467
|
+
await self._execute(
|
|
468
|
+
session,
|
|
438
469
|
update(self._refresh_token_model)
|
|
439
470
|
.where(self._refresh_token_model.user_id == user_id)
|
|
440
|
-
.values(revoked=True)
|
|
471
|
+
.values(revoked=True),
|
|
441
472
|
)
|
|
442
473
|
await self._commit(session)
|
|
443
474
|
|
|
@@ -446,10 +477,11 @@ class _BaseSQLAlchemyAdapter(
|
|
|
446
477
|
async with self._begin() as session:
|
|
447
478
|
result = cast(
|
|
448
479
|
"CursorResult[Any]",
|
|
449
|
-
await
|
|
480
|
+
await self._execute(
|
|
481
|
+
session,
|
|
450
482
|
delete(self._refresh_token_model).where(
|
|
451
483
|
self._refresh_token_model.expires_at < cutoff
|
|
452
|
-
)
|
|
484
|
+
),
|
|
453
485
|
),
|
|
454
486
|
)
|
|
455
487
|
await self._commit(session)
|
|
@@ -459,8 +491,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
459
491
|
model = self._refresh_token_model
|
|
460
492
|
now = datetime.now(timezone.utc)
|
|
461
493
|
async with self._begin() as session:
|
|
462
|
-
result = await
|
|
463
|
-
select(model).where(model.user_id == user_id).order_by(model.created_at)
|
|
494
|
+
result = await self._execute(
|
|
495
|
+
session, select(model).where(model.user_id == user_id).order_by(model.created_at)
|
|
464
496
|
)
|
|
465
497
|
rows = result.scalars().all()
|
|
466
498
|
|
|
@@ -496,19 +528,21 @@ class _BaseSQLAlchemyAdapter(
|
|
|
496
528
|
# rows (re-revoking an already-revoked family) reports rowcount 0,
|
|
497
529
|
# which would wrongly 404 a session the user still owns. The contract
|
|
498
530
|
# is idempotent: True if a row existed for (user_id, family_id).
|
|
499
|
-
existing = await
|
|
531
|
+
existing = await self._execute(
|
|
532
|
+
session,
|
|
500
533
|
select(model.id)
|
|
501
534
|
.where(model.user_id == user_id)
|
|
502
535
|
.where(model.family_id == family_id)
|
|
503
|
-
.limit(1)
|
|
536
|
+
.limit(1),
|
|
504
537
|
)
|
|
505
538
|
if existing.first() is None:
|
|
506
539
|
return False
|
|
507
|
-
await
|
|
540
|
+
await self._execute(
|
|
541
|
+
session,
|
|
508
542
|
update(model)
|
|
509
543
|
.where(model.user_id == user_id)
|
|
510
544
|
.where(model.family_id == family_id)
|
|
511
|
-
.values(revoked=True)
|
|
545
|
+
.values(revoked=True),
|
|
512
546
|
)
|
|
513
547
|
await self._commit(session)
|
|
514
548
|
return True
|
|
@@ -518,12 +552,13 @@ class _BaseSQLAlchemyAdapter(
|
|
|
518
552
|
async with self._begin() as session:
|
|
519
553
|
result = cast(
|
|
520
554
|
"CursorResult[Any]",
|
|
521
|
-
await
|
|
555
|
+
await self._execute(
|
|
556
|
+
session,
|
|
522
557
|
update(model)
|
|
523
558
|
.where(model.user_id == user_id)
|
|
524
559
|
.where(model.family_id != keep_family_id)
|
|
525
560
|
.where(model.revoked.is_(False))
|
|
526
|
-
.values(revoked=True)
|
|
561
|
+
.values(revoked=True),
|
|
527
562
|
),
|
|
528
563
|
)
|
|
529
564
|
await self._commit(session)
|
|
@@ -531,10 +566,11 @@ class _BaseSQLAlchemyAdapter(
|
|
|
531
566
|
|
|
532
567
|
async def set_user_verified(self, user_id: UserID) -> None:
|
|
533
568
|
async with self._begin() as session:
|
|
534
|
-
await
|
|
569
|
+
await self._execute(
|
|
570
|
+
session,
|
|
535
571
|
update(self._user_model)
|
|
536
572
|
.where(self._user_model.id == user_id)
|
|
537
|
-
.values(is_verified=True)
|
|
573
|
+
.values(is_verified=True),
|
|
538
574
|
)
|
|
539
575
|
await self._commit(session)
|
|
540
576
|
|
|
@@ -542,14 +578,18 @@ class _BaseSQLAlchemyAdapter(
|
|
|
542
578
|
role_model = self._require(self._role_model, "Role assignment")
|
|
543
579
|
|
|
544
580
|
async with self._begin() as session:
|
|
545
|
-
result = await
|
|
581
|
+
result = await self._execute(
|
|
582
|
+
session, select(role_model).where(role_model.name == role_name)
|
|
583
|
+
)
|
|
546
584
|
role = result.scalars().first()
|
|
547
585
|
if role is None:
|
|
548
586
|
role = role_model(name=role_name)
|
|
549
587
|
session.add(role)
|
|
550
588
|
await session.flush()
|
|
551
589
|
|
|
552
|
-
result = await
|
|
590
|
+
result = await self._execute(
|
|
591
|
+
session, self._user_query().where(self._user_model.id == user_id)
|
|
592
|
+
)
|
|
553
593
|
user = result.scalars().first()
|
|
554
594
|
if user and role not in user.roles:
|
|
555
595
|
user.roles.append(role)
|
|
@@ -558,7 +598,9 @@ class _BaseSQLAlchemyAdapter(
|
|
|
558
598
|
|
|
559
599
|
async def remove_role(self, user_id: UserID, role_name: str) -> None:
|
|
560
600
|
async with self._begin() as session:
|
|
561
|
-
result = await
|
|
601
|
+
result = await self._execute(
|
|
602
|
+
session, self._user_query().where(self._user_model.id == user_id)
|
|
603
|
+
)
|
|
562
604
|
user = result.scalars().first()
|
|
563
605
|
if user:
|
|
564
606
|
user.roles = [r for r in user.roles if r.name != role_name]
|
|
@@ -573,14 +615,15 @@ class _BaseSQLAlchemyAdapter(
|
|
|
573
615
|
role_permission_model = self._require(self._role_permission_model, "Permissions")
|
|
574
616
|
|
|
575
617
|
async with self._begin() as session:
|
|
576
|
-
result = await
|
|
618
|
+
result = await self._execute(
|
|
619
|
+
session,
|
|
577
620
|
select(permission_model.name)
|
|
578
621
|
.join(
|
|
579
622
|
role_permission_model,
|
|
580
623
|
permission_model.id == role_permission_model.permission_id,
|
|
581
624
|
)
|
|
582
625
|
.join(role_model, role_model.id == role_permission_model.role_id)
|
|
583
|
-
.where(role_model.name.in_(role_names))
|
|
626
|
+
.where(role_model.name.in_(role_names)),
|
|
584
627
|
)
|
|
585
628
|
return list(set(result.scalars().all()))
|
|
586
629
|
|
|
@@ -590,14 +633,15 @@ class _BaseSQLAlchemyAdapter(
|
|
|
590
633
|
role_permission_model = self._require(self._role_permission_model, "Permissions")
|
|
591
634
|
|
|
592
635
|
async with self._begin() as session:
|
|
593
|
-
result = await
|
|
636
|
+
result = await self._execute(
|
|
637
|
+
session,
|
|
594
638
|
select(permission_model.name)
|
|
595
639
|
.join(
|
|
596
640
|
role_permission_model,
|
|
597
641
|
permission_model.id == role_permission_model.permission_id,
|
|
598
642
|
)
|
|
599
643
|
.join(role_model, role_model.id == role_permission_model.role_id)
|
|
600
|
-
.where(role_model.name == role_name)
|
|
644
|
+
.where(role_model.name == role_name),
|
|
601
645
|
)
|
|
602
646
|
return list(result.scalars().all())
|
|
603
647
|
|
|
@@ -607,15 +651,17 @@ class _BaseSQLAlchemyAdapter(
|
|
|
607
651
|
role_permission_model = self._require(self._role_permission_model, "Permissions")
|
|
608
652
|
|
|
609
653
|
async with self._begin() as session:
|
|
610
|
-
result = await
|
|
654
|
+
result = await self._execute(
|
|
655
|
+
session, select(role_model).where(role_model.name == role_name)
|
|
656
|
+
)
|
|
611
657
|
role = result.scalars().first()
|
|
612
658
|
if role is None:
|
|
613
659
|
role = role_model(name=role_name)
|
|
614
660
|
session.add(role)
|
|
615
661
|
await session.flush()
|
|
616
662
|
|
|
617
|
-
result = await
|
|
618
|
-
select(permission_model).where(permission_model.name == permission)
|
|
663
|
+
result = await self._execute(
|
|
664
|
+
session, select(permission_model).where(permission_model.name == permission)
|
|
619
665
|
)
|
|
620
666
|
perm = result.scalars().first()
|
|
621
667
|
if perm is None:
|
|
@@ -623,11 +669,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
623
669
|
session.add(perm)
|
|
624
670
|
await session.flush()
|
|
625
671
|
|
|
626
|
-
result = await
|
|
672
|
+
result = await self._execute(
|
|
673
|
+
session,
|
|
627
674
|
select(role_permission_model).where(
|
|
628
675
|
role_permission_model.role_id == role.id,
|
|
629
676
|
role_permission_model.permission_id == perm.id,
|
|
630
|
-
)
|
|
677
|
+
),
|
|
631
678
|
)
|
|
632
679
|
if result.scalars().first() is None:
|
|
633
680
|
session.add(role_permission_model(role_id=role.id, permission_id=perm.id))
|
|
@@ -639,23 +686,26 @@ class _BaseSQLAlchemyAdapter(
|
|
|
639
686
|
role_permission_model = self._require(self._role_permission_model, "Permissions")
|
|
640
687
|
|
|
641
688
|
async with self._begin() as session:
|
|
642
|
-
result = await
|
|
689
|
+
result = await self._execute(
|
|
690
|
+
session, select(role_model).where(role_model.name == role_name)
|
|
691
|
+
)
|
|
643
692
|
role = result.scalars().first()
|
|
644
693
|
if role is None:
|
|
645
694
|
return
|
|
646
695
|
|
|
647
|
-
result = await
|
|
648
|
-
select(permission_model).where(permission_model.name == permission)
|
|
696
|
+
result = await self._execute(
|
|
697
|
+
session, select(permission_model).where(permission_model.name == permission)
|
|
649
698
|
)
|
|
650
699
|
perm = result.scalars().first()
|
|
651
700
|
if perm is None:
|
|
652
701
|
return
|
|
653
702
|
|
|
654
|
-
result = await
|
|
703
|
+
result = await self._execute(
|
|
704
|
+
session,
|
|
655
705
|
select(role_permission_model).where(
|
|
656
706
|
role_permission_model.role_id == role.id,
|
|
657
707
|
role_permission_model.permission_id == perm.id,
|
|
658
|
-
)
|
|
708
|
+
),
|
|
659
709
|
)
|
|
660
710
|
link = result.scalars().first()
|
|
661
711
|
if link:
|
|
@@ -678,11 +728,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
678
728
|
async def get_oauth_account(self, provider: str, provider_user_id: str) -> OAuthAccount | None:
|
|
679
729
|
oauth_model = self._require(self._oauth_account_model, "OAuth")
|
|
680
730
|
async with self._begin() as session:
|
|
681
|
-
result = await
|
|
731
|
+
result = await self._execute(
|
|
732
|
+
session,
|
|
682
733
|
select(oauth_model).where(
|
|
683
734
|
oauth_model.provider == provider,
|
|
684
735
|
oauth_model.provider_user_id == provider_user_id,
|
|
685
|
-
)
|
|
736
|
+
),
|
|
686
737
|
)
|
|
687
738
|
row = result.scalars().first()
|
|
688
739
|
return self._to_oauth_account(row) if row else None
|
|
@@ -690,8 +741,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
690
741
|
async def get_user_oauth_accounts(self, user_id: UserID) -> list[OAuthAccount]:
|
|
691
742
|
oauth_model = self._require(self._oauth_account_model, "OAuth")
|
|
692
743
|
async with self._begin() as session:
|
|
693
|
-
result = await
|
|
694
|
-
select(oauth_model).where(oauth_model.user_id == user_id)
|
|
744
|
+
result = await self._execute(
|
|
745
|
+
session, select(oauth_model).where(oauth_model.user_id == user_id)
|
|
695
746
|
)
|
|
696
747
|
return [self._to_oauth_account(row) for row in result.scalars().all()]
|
|
697
748
|
|
|
@@ -713,11 +764,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
713
764
|
# Concurrent OAuth callback for the same (provider, provider_user_id)
|
|
714
765
|
# won the insert. Return the existing row; both callers linked the
|
|
715
766
|
# same identity, which is the intended outcome.
|
|
716
|
-
result = await
|
|
767
|
+
result = await self._execute(
|
|
768
|
+
session,
|
|
717
769
|
select(oauth_model).where(
|
|
718
770
|
oauth_model.provider == data.provider,
|
|
719
771
|
oauth_model.provider_user_id == data.provider_user_id,
|
|
720
|
-
)
|
|
772
|
+
),
|
|
721
773
|
)
|
|
722
774
|
existing = result.scalars().first()
|
|
723
775
|
if existing is not None:
|
|
@@ -732,13 +784,14 @@ class _BaseSQLAlchemyAdapter(
|
|
|
732
784
|
oauth_model = self._require(self._oauth_account_model, "OAuth")
|
|
733
785
|
async with self._begin() as session:
|
|
734
786
|
if data:
|
|
735
|
-
await
|
|
787
|
+
await self._execute(
|
|
788
|
+
session,
|
|
736
789
|
update(oauth_model)
|
|
737
790
|
.where(
|
|
738
791
|
oauth_model.provider == provider,
|
|
739
792
|
oauth_model.provider_user_id == provider_user_id,
|
|
740
793
|
)
|
|
741
|
-
.values(**data)
|
|
794
|
+
.values(**data),
|
|
742
795
|
)
|
|
743
796
|
await self._commit(session)
|
|
744
797
|
return await self.get_oauth_account(provider, provider_user_id)
|
|
@@ -746,11 +799,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
746
799
|
async def delete_oauth_account(self, provider: str, provider_user_id: str) -> None:
|
|
747
800
|
oauth_model = self._require(self._oauth_account_model, "OAuth")
|
|
748
801
|
async with self._begin() as session:
|
|
749
|
-
result = await
|
|
802
|
+
result = await self._execute(
|
|
803
|
+
session,
|
|
750
804
|
select(oauth_model).where(
|
|
751
805
|
oauth_model.provider == provider,
|
|
752
806
|
oauth_model.provider_user_id == provider_user_id,
|
|
753
|
-
)
|
|
807
|
+
),
|
|
754
808
|
)
|
|
755
809
|
row = result.scalars().first()
|
|
756
810
|
if row:
|
|
@@ -776,8 +830,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
776
830
|
async def get_passkey_by_credential_id(self, credential_id: str) -> PasskeyCredential | None:
|
|
777
831
|
passkey_model = self._require(self._passkey_model, "Passkeys")
|
|
778
832
|
async with self._begin() as session:
|
|
779
|
-
result = await
|
|
780
|
-
select(passkey_model).where(passkey_model.credential_id == credential_id)
|
|
833
|
+
result = await self._execute(
|
|
834
|
+
session, select(passkey_model).where(passkey_model.credential_id == credential_id)
|
|
781
835
|
)
|
|
782
836
|
row = result.scalars().first()
|
|
783
837
|
return self._to_passkey(row) if row else None
|
|
@@ -785,8 +839,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
785
839
|
async def get_user_passkeys(self, user_id: UserID) -> list[PasskeyCredential]:
|
|
786
840
|
passkey_model = self._require(self._passkey_model, "Passkeys")
|
|
787
841
|
async with self._begin() as session:
|
|
788
|
-
result = await
|
|
789
|
-
select(passkey_model).where(passkey_model.user_id == user_id)
|
|
842
|
+
result = await self._execute(
|
|
843
|
+
session, select(passkey_model).where(passkey_model.user_id == user_id)
|
|
790
844
|
)
|
|
791
845
|
return [self._to_passkey(row) for row in result.scalars().all()]
|
|
792
846
|
|
|
@@ -813,11 +867,12 @@ class _BaseSQLAlchemyAdapter(
|
|
|
813
867
|
async with self._begin() as session:
|
|
814
868
|
result = cast(
|
|
815
869
|
"CursorResult[Any]",
|
|
816
|
-
await
|
|
870
|
+
await self._execute(
|
|
871
|
+
session,
|
|
817
872
|
update(passkey_model)
|
|
818
873
|
.where(passkey_model.credential_id == credential_id)
|
|
819
874
|
.where(passkey_model.sign_count < sign_count)
|
|
820
|
-
.values(sign_count=sign_count, last_used_at=now)
|
|
875
|
+
.values(sign_count=sign_count, last_used_at=now),
|
|
821
876
|
),
|
|
822
877
|
)
|
|
823
878
|
if result.rowcount == 0:
|
|
@@ -825,10 +880,11 @@ class _BaseSQLAlchemyAdapter(
|
|
|
825
880
|
# counter (both stored and new are 0) or a concurrent writer already wrote
|
|
826
881
|
# a value >= ours. Touch last_used_at for the no-counter case; caller
|
|
827
882
|
# decides whether to reject based on the new_sign_count value.
|
|
828
|
-
await
|
|
883
|
+
await self._execute(
|
|
884
|
+
session,
|
|
829
885
|
update(passkey_model)
|
|
830
886
|
.where(passkey_model.credential_id == credential_id)
|
|
831
|
-
.values(last_used_at=now)
|
|
887
|
+
.values(last_used_at=now),
|
|
832
888
|
)
|
|
833
889
|
await self._commit(session)
|
|
834
890
|
return False
|
|
@@ -838,8 +894,8 @@ class _BaseSQLAlchemyAdapter(
|
|
|
838
894
|
async def delete_passkey(self, passkey_id: UUID) -> None:
|
|
839
895
|
passkey_model = self._require(self._passkey_model, "Passkeys")
|
|
840
896
|
async with self._begin() as session:
|
|
841
|
-
result = await
|
|
842
|
-
select(passkey_model).where(passkey_model.id == passkey_id)
|
|
897
|
+
result = await self._execute(
|
|
898
|
+
session, select(passkey_model).where(passkey_model.id == passkey_id)
|
|
843
899
|
)
|
|
844
900
|
row = result.scalars().first()
|
|
845
901
|
if row:
|
|
@@ -40,6 +40,14 @@ class AuthRateLimits:
|
|
|
40
40
|
# bucket, so failed attempts cannot exhaust an IP's sign-in budget.
|
|
41
41
|
reauth: int = 5
|
|
42
42
|
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
for name, value in vars(self).items():
|
|
45
|
+
if value < 1:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"AUTH_RATE_LIMITS.{name} must be at least 1; "
|
|
48
|
+
f"{value} would reject every request to that route."
|
|
49
|
+
)
|
|
50
|
+
|
|
43
51
|
|
|
44
52
|
class FullAuthConfig(BaseSettings):
|
|
45
53
|
model_config = SettingsConfigDict(
|
|
@@ -61,19 +69,20 @@ class FullAuthConfig(BaseSettings):
|
|
|
61
69
|
SECRET_KEY: str | None = None
|
|
62
70
|
ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256"
|
|
63
71
|
|
|
64
|
-
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
65
|
-
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
|
72
|
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, ge=1)
|
|
73
|
+
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=30, ge=1)
|
|
66
74
|
REFRESH_TOKEN_ROTATION: bool = True
|
|
67
|
-
JWT_LEEWAY_SECONDS: int = 30
|
|
75
|
+
JWT_LEEWAY_SECONDS: int = Field(default=30, ge=0)
|
|
68
76
|
|
|
69
|
-
PASSWORD_RESET_EXPIRE_MINUTES: int = 15
|
|
70
|
-
EMAIL_VERIFY_EXPIRE_MINUTES: int = 1440
|
|
77
|
+
PASSWORD_RESET_EXPIRE_MINUTES: int = Field(default=15, ge=1)
|
|
78
|
+
EMAIL_VERIFY_EXPIRE_MINUTES: int = Field(default=1440, ge=1)
|
|
71
79
|
|
|
72
80
|
PASSWORD_HASH_ALGORITHM: Literal["argon2id", "bcrypt"] = "argon2id"
|
|
73
|
-
PASSWORD_MIN_LENGTH: int = 8
|
|
81
|
+
PASSWORD_MIN_LENGTH: int = Field(default=8, ge=1)
|
|
74
82
|
# Upper bound so an attacker can't spend the server's CPU hashing a huge
|
|
75
83
|
# body. bcrypt caps at 72 bytes anyway; argon2 will hash whatever it is given.
|
|
76
|
-
|
|
84
|
+
# 0 disables the cap, hence ge=0 rather than ge=1.
|
|
85
|
+
PASSWORD_MAX_LENGTH: int = Field(default=4096, ge=0)
|
|
77
86
|
|
|
78
87
|
LOGIN_FIELD: str = "email"
|
|
79
88
|
# When True, login runs a dummy password verify on unknown-user / no-password
|
|
@@ -86,12 +95,12 @@ class FullAuthConfig(BaseSettings):
|
|
|
86
95
|
# destructive action (deleting the account, setting a first password).
|
|
87
96
|
# Supplying the current password proves it too. 0 accepts only the password,
|
|
88
97
|
# which locks passwordless accounts out of those actions.
|
|
89
|
-
REAUTH_MAX_AGE_SECONDS: int = 300
|
|
98
|
+
REAUTH_MAX_AGE_SECONDS: int = Field(default=300, ge=0)
|
|
90
99
|
|
|
91
100
|
LOCKOUT_ENABLED: bool = True
|
|
92
101
|
LOCKOUT_BACKEND: Literal["memory", "redis"] = "memory"
|
|
93
|
-
MAX_LOGIN_ATTEMPTS: int = 5
|
|
94
|
-
LOCKOUT_DURATION_MINUTES: int = 15
|
|
102
|
+
MAX_LOGIN_ATTEMPTS: int = Field(default=5, ge=1)
|
|
103
|
+
LOCKOUT_DURATION_MINUTES: int = Field(default=15, ge=1)
|
|
95
104
|
|
|
96
105
|
RATE_LIMIT_BACKEND: Literal["memory", "redis"] = "memory"
|
|
97
106
|
TRUSTED_PROXY_HEADERS: Annotated[list[str], NoDecode] = []
|
|
@@ -104,14 +113,14 @@ class FullAuthConfig(BaseSettings):
|
|
|
104
113
|
|
|
105
114
|
AUTH_RATE_LIMIT_ENABLED: bool = True
|
|
106
115
|
AUTH_RATE_LIMITS: AuthRateLimits = Field(default_factory=AuthRateLimits)
|
|
107
|
-
AUTH_RATE_LIMIT_WINDOW_SECONDS: int = 60
|
|
116
|
+
AUTH_RATE_LIMIT_WINDOW_SECONDS: int = Field(default=60, ge=1)
|
|
108
117
|
|
|
109
118
|
REDIS_URL: str | None = None
|
|
110
119
|
|
|
111
120
|
BLACKLIST_ENABLED: bool = True
|
|
112
121
|
BLACKLIST_BACKEND: Literal["memory", "redis"] = "memory"
|
|
113
122
|
|
|
114
|
-
OAUTH_STATE_EXPIRE_SECONDS: int = 300
|
|
123
|
+
OAUTH_STATE_EXPIRE_SECONDS: int = Field(default=300, ge=1)
|
|
115
124
|
OAUTH_AUTO_LINK_BY_EMAIL: bool = True
|
|
116
125
|
OAUTH_PKCE_ENABLED: bool = True
|
|
117
126
|
|
|
@@ -124,7 +133,7 @@ class FullAuthConfig(BaseSettings):
|
|
|
124
133
|
PASSKEY_RP_NAME: str | None = None
|
|
125
134
|
PASSKEY_ORIGINS: Annotated[list[str], NoDecode] = []
|
|
126
135
|
PASSKEY_CHALLENGE_BACKEND: Literal["memory", "redis"] = "memory"
|
|
127
|
-
PASSKEY_CHALLENGE_TTL: int = 60
|
|
136
|
+
PASSKEY_CHALLENGE_TTL: int = Field(default=60, ge=1)
|
|
128
137
|
PASSKEY_REQUIRE_USER_VERIFICATION: bool = True
|
|
129
138
|
|
|
130
139
|
API_PREFIX: str = "/api/v1"
|