bluefox-auth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bluefox_auth-0.1.0/.github/workflows/ci.yml +11 -0
- bluefox_auth-0.1.0/.github/workflows/publish.yml +20 -0
- bluefox_auth-0.1.0/.gitignore +207 -0
- bluefox_auth-0.1.0/Makefile +33 -0
- bluefox_auth-0.1.0/PKG-INFO +15 -0
- bluefox_auth-0.1.0/README.md +3 -0
- bluefox_auth-0.1.0/bluefox_auth/__init__.py +0 -0
- bluefox_auth-0.1.0/bluefox_auth/models.py +54 -0
- bluefox_auth-0.1.0/bluefox_auth/passwords.py +21 -0
- bluefox_auth-0.1.0/bluefox_auth/utils.py +3 -0
- bluefox_auth-0.1.0/pyproject.toml +44 -0
- bluefox_auth-0.1.0/tests/__init__.py +0 -0
- bluefox_auth-0.1.0/tests/conftest.py +10 -0
- bluefox_auth-0.1.0/tests/test_models.py +116 -0
- bluefox_auth-0.1.0/tests/test_passwords.py +46 -0
- bluefox_auth-0.1.0/tests/test_utils.py +13 -0
- bluefox_auth-0.1.0/uv.lock +1088 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
environment: pypi
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- uses: astral-sh/setup-uv@v5
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
.PHONY: install test lint format type-check ci fix clean
|
|
2
|
+
|
|
3
|
+
# Install all dependencies (dev + test)
|
|
4
|
+
install:
|
|
5
|
+
uv sync --group dev --extra test
|
|
6
|
+
|
|
7
|
+
# Run all tests
|
|
8
|
+
test:
|
|
9
|
+
uv run pytest tests/ -v --tb=short
|
|
10
|
+
|
|
11
|
+
# Lint with ruff
|
|
12
|
+
lint:
|
|
13
|
+
uv run ruff check bluefox_auth/ tests/
|
|
14
|
+
|
|
15
|
+
# Type check with ty
|
|
16
|
+
type-check:
|
|
17
|
+
uv run ty check bluefox_auth/ tests/
|
|
18
|
+
|
|
19
|
+
# Check formatting
|
|
20
|
+
format:
|
|
21
|
+
uv run ruff format --check bluefox_auth/ tests/
|
|
22
|
+
|
|
23
|
+
# Full CI pipeline: lint, format, type-check, tests
|
|
24
|
+
ci: lint format type-check test
|
|
25
|
+
|
|
26
|
+
# Auto-fix lint issues
|
|
27
|
+
fix:
|
|
28
|
+
uv run ruff check --fix bluefox_auth/ tests/
|
|
29
|
+
uv run ruff format bluefox_auth/ tests/
|
|
30
|
+
|
|
31
|
+
# Remove build artifacts
|
|
32
|
+
clean:
|
|
33
|
+
rm -rf dist/ *.egg-info .pytest_cache
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bluefox-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JWT authentication, user management, and authorization for Bluefox apps
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: bcrypt>=4.1
|
|
8
|
+
Requires-Dist: bluefox-core<1.0,>=0.1.0
|
|
9
|
+
Requires-Dist: pyjwt>=2.8
|
|
10
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: bluefox-test<1.0,>=0.1.0; extra == 'test'
|
|
13
|
+
Requires-Dist: httpx>=0.27; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from bluefox_core.database import BluefoxBase
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BluefoxUser(BluefoxBase):
|
|
9
|
+
"""
|
|
10
|
+
Base user model for authentication.
|
|
11
|
+
|
|
12
|
+
Use directly or extend with custom fields:
|
|
13
|
+
|
|
14
|
+
class User(BluefoxUser):
|
|
15
|
+
__tablename__ = "users"
|
|
16
|
+
company_id: Mapped[int] = mapped_column(ForeignKey("companies.id"))
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__tablename__ = "users"
|
|
20
|
+
|
|
21
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
22
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
|
23
|
+
password_hash: Mapped[str] = mapped_column(String(255))
|
|
24
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
25
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
26
|
+
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
27
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
28
|
+
DateTime(timezone=True), server_default=func.now()
|
|
29
|
+
)
|
|
30
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
31
|
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RefreshToken(BluefoxBase):
|
|
36
|
+
"""
|
|
37
|
+
Server-side refresh token record for rotation and revocation.
|
|
38
|
+
|
|
39
|
+
Each refresh token belongs to a "family" — a chain of rotated tokens
|
|
40
|
+
originating from a single login. If a revoked token is replayed,
|
|
41
|
+
the entire family is invalidated (reuse detection).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__tablename__ = "refresh_tokens"
|
|
45
|
+
|
|
46
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
47
|
+
jti: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
48
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
49
|
+
family_id: Mapped[str] = mapped_column(String(64), index=True)
|
|
50
|
+
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
51
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
52
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
53
|
+
DateTime(timezone=True), server_default=func.now()
|
|
54
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import bcrypt
|
|
2
|
+
|
|
3
|
+
# Pre-computed bcrypt hash for timing-safe comparisons when user is not found.
|
|
4
|
+
_DUMMY_HASH = "$2b$12$LJ3m4ys3Lg2VBe4sFNGDe.NU0TGi5LjGnS/RhOsKNBQPNKZHPIsfG"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def hash_password(plain: str) -> str:
|
|
8
|
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
12
|
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_password_timing_safe(plain: str, hashed: str | None) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Verify a password, falling back to a dummy hash if hashed is None.
|
|
18
|
+
Prevents timing-based user enumeration.
|
|
19
|
+
"""
|
|
20
|
+
target = hashed if hashed is not None else _DUMMY_HASH
|
|
21
|
+
return verify_password(plain, target) and hashed is not None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bluefox-auth"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "JWT authentication, user management, and authorization for Bluefox apps"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"bluefox-core>=0.1.0,<1.0",
|
|
13
|
+
"PyJWT>=2.8",
|
|
14
|
+
"bcrypt>=4.1",
|
|
15
|
+
"python-multipart>=0.0.9",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
test = [
|
|
20
|
+
"bluefox-test>=0.1.0,<1.0",
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"pytest-asyncio>=0.24",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ruff>=0.15",
|
|
29
|
+
"ty>=0.0.23",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py312"
|
|
38
|
+
line-length = 99
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
42
|
+
|
|
43
|
+
[tool.ty.environment]
|
|
44
|
+
python-version = "3.12"
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from datetime import UTC
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from sqlalchemy.exc import IntegrityError
|
|
6
|
+
|
|
7
|
+
from bluefox_auth.models import BluefoxUser, RefreshToken
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def test_bluefox_user__creates_with_required_fields(db):
|
|
11
|
+
user = BluefoxUser(
|
|
12
|
+
email="test@example.com",
|
|
13
|
+
password_hash="hashed_password",
|
|
14
|
+
)
|
|
15
|
+
db.add(user)
|
|
16
|
+
await db.flush()
|
|
17
|
+
|
|
18
|
+
assert user.id is not None
|
|
19
|
+
assert user.email == "test@example.com"
|
|
20
|
+
assert user.password_hash == "hashed_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_bluefox_user__defaults(db):
|
|
24
|
+
user = BluefoxUser(
|
|
25
|
+
email="defaults@example.com",
|
|
26
|
+
password_hash="hashed_password",
|
|
27
|
+
)
|
|
28
|
+
db.add(user)
|
|
29
|
+
await db.flush()
|
|
30
|
+
|
|
31
|
+
assert user.is_active is True
|
|
32
|
+
assert user.is_superuser is False
|
|
33
|
+
assert user.email_verified is False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_bluefox_user__email_unique_constraint(db):
|
|
37
|
+
user1 = BluefoxUser(email="dupe@example.com", password_hash="hash1")
|
|
38
|
+
user2 = BluefoxUser(email="dupe@example.com", password_hash="hash2")
|
|
39
|
+
db.add(user1)
|
|
40
|
+
await db.flush()
|
|
41
|
+
db.add(user2)
|
|
42
|
+
with pytest.raises(IntegrityError):
|
|
43
|
+
await db.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_bluefox_user__timestamps_set_on_create(db):
|
|
47
|
+
user = BluefoxUser(email="ts@example.com", password_hash="hash")
|
|
48
|
+
db.add(user)
|
|
49
|
+
await db.flush()
|
|
50
|
+
await db.refresh(user)
|
|
51
|
+
|
|
52
|
+
assert user.created_at is not None
|
|
53
|
+
assert user.updated_at is not None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def test_refresh_token__creates_with_required_fields(db):
|
|
57
|
+
user = BluefoxUser(email="rt@example.com", password_hash="hash")
|
|
58
|
+
db.add(user)
|
|
59
|
+
await db.flush()
|
|
60
|
+
|
|
61
|
+
from datetime import datetime
|
|
62
|
+
|
|
63
|
+
token = RefreshToken(
|
|
64
|
+
jti="test-jti-001",
|
|
65
|
+
user_id=user.id,
|
|
66
|
+
family_id="family-001",
|
|
67
|
+
expires_at=datetime(2099, 1, 1, tzinfo=UTC),
|
|
68
|
+
)
|
|
69
|
+
db.add(token)
|
|
70
|
+
await db.flush()
|
|
71
|
+
|
|
72
|
+
assert token.id is not None
|
|
73
|
+
assert token.jti == "test-jti-001"
|
|
74
|
+
assert token.user_id == user.id
|
|
75
|
+
assert token.family_id == "family-001"
|
|
76
|
+
assert token.is_revoked is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_refresh_token__jti_unique_constraint(db):
|
|
80
|
+
user = BluefoxUser(email="jti@example.com", password_hash="hash")
|
|
81
|
+
db.add(user)
|
|
82
|
+
await db.flush()
|
|
83
|
+
|
|
84
|
+
from datetime import datetime
|
|
85
|
+
|
|
86
|
+
expires = datetime(2099, 1, 1, tzinfo=UTC)
|
|
87
|
+
t1 = RefreshToken(jti="dupe-jti", user_id=user.id, family_id="f1", expires_at=expires)
|
|
88
|
+
t2 = RefreshToken(jti="dupe-jti", user_id=user.id, family_id="f2", expires_at=expires)
|
|
89
|
+
db.add(t1)
|
|
90
|
+
await db.flush()
|
|
91
|
+
db.add(t2)
|
|
92
|
+
with pytest.raises(IntegrityError):
|
|
93
|
+
await db.flush()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def test_refresh_token__cascade_deletes_on_user_delete(db):
|
|
97
|
+
user = BluefoxUser(email="cascade@example.com", password_hash="hash")
|
|
98
|
+
db.add(user)
|
|
99
|
+
await db.flush()
|
|
100
|
+
|
|
101
|
+
from datetime import datetime
|
|
102
|
+
|
|
103
|
+
token = RefreshToken(
|
|
104
|
+
jti="cascade-jti",
|
|
105
|
+
user_id=user.id,
|
|
106
|
+
family_id="f1",
|
|
107
|
+
expires_at=datetime(2099, 1, 1, tzinfo=UTC),
|
|
108
|
+
)
|
|
109
|
+
db.add(token)
|
|
110
|
+
await db.flush()
|
|
111
|
+
|
|
112
|
+
await db.delete(user)
|
|
113
|
+
await db.flush()
|
|
114
|
+
|
|
115
|
+
result = await db.execute(select(RefreshToken).where(RefreshToken.jti == "cascade-jti"))
|
|
116
|
+
assert result.scalar_one_or_none() is None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from bluefox_auth.passwords import hash_password, verify_password, verify_password_timing_safe
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_hash_password__returns_bcrypt_hash():
|
|
5
|
+
hashed = hash_password("my-password")
|
|
6
|
+
assert hashed.startswith("$2b$")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_verify_password__correct_password():
|
|
10
|
+
hashed = hash_password("correct-horse")
|
|
11
|
+
assert verify_password("correct-horse", hashed) is True
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_verify_password__wrong_password():
|
|
15
|
+
hashed = hash_password("correct-horse")
|
|
16
|
+
assert verify_password("wrong-horse", hashed) is False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_hash_password__different_salts():
|
|
20
|
+
h1 = hash_password("same-password")
|
|
21
|
+
h2 = hash_password("same-password")
|
|
22
|
+
assert h1 != h2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_hash_password__rejects_over_72_bytes():
|
|
26
|
+
"""Documents the bcrypt limitation: passwords over 72 bytes raise ValueError."""
|
|
27
|
+
import pytest
|
|
28
|
+
|
|
29
|
+
over_limit = "a" * 73
|
|
30
|
+
with pytest.raises(ValueError, match="72 bytes"):
|
|
31
|
+
hash_password(over_limit)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_verify_password_timing_safe__correct_password():
|
|
35
|
+
hashed = hash_password("safe-pass")
|
|
36
|
+
assert verify_password_timing_safe("safe-pass", hashed) is True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_verify_password_timing_safe__wrong_password():
|
|
40
|
+
hashed = hash_password("safe-pass")
|
|
41
|
+
assert verify_password_timing_safe("wrong-pass", hashed) is False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_verify_password_timing_safe__none_hash_returns_false():
|
|
45
|
+
# Should still run bcrypt (against dummy hash) but return False
|
|
46
|
+
assert verify_password_timing_safe("any-password", None) is False
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from bluefox_auth.utils import normalize_email
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_normalize_email__lowercases():
|
|
5
|
+
assert normalize_email("Hugo@Example.COM") == "hugo@example.com"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_normalize_email__strips_whitespace():
|
|
9
|
+
assert normalize_email(" user@example.com ") == "user@example.com"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_normalize_email__combined():
|
|
13
|
+
assert normalize_email(" Hugo@Example.COM ") == "hugo@example.com"
|