Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support both Pydantic v1 and v2 #166

Merged
merged 9 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10']
python-version: ['3.8', '3.9', '3.10', '3.11']
pydantic-version: ['>=1.0,<2.0', '>=2.0,<3.0']

steps:
- uses: actions/checkout@v3
Expand All @@ -27,6 +28,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install 'pydantic${{ matrix.pydantic-version }}'
- name: Test with unittest
run: |
python -m unittest
Expand Down
10 changes: 6 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
annotated-types==0.5.0
asn1crypto==1.4.0
black==21.9b0
cbor2==5.4.2.post1
cffi==1.15.0
click==8.0.3
cryptography==41.0.1
mccabe==0.6.1
mypy==0.910
mypy-extensions==0.4.3
mypy==1.4.1
mypy-extensions==1.0.0
pathspec==0.9.0
platformdirs==2.4.0
pycodestyle==2.8.0
pycparser==2.20
pydantic==1.10.11
pydantic==2.1.1
pydantic_core==2.4.0
pyflakes==2.4.0
pyOpenSSL==23.2.0
regex==2021.10.8
six==1.16.0
toml==0.10.2
tomli==1.2.1
typing-extensions==4.2.0
typing_extensions==4.7.1
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def find_version(*file_paths):
'asn1crypto>=1.4.0',
'cbor2>=5.4.2.post1',
'cryptography>=41.0.1',
'pydantic>=1.10.11,<2.0a0',
MasterKale marked this conversation as resolved.
Show resolved Hide resolved
'pydantic>=1.10.11',
'pyOpenSSL>=23.2.0',
]
)
5 changes: 2 additions & 3 deletions tests/test_verify_registration_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from unittest import TestCase

import cbor2
from pydantic import ValidationError
from webauthn.helpers import base64url_to_bytes, bytes_to_base64url
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.known_root_certs import globalsign_r2
Expand Down Expand Up @@ -87,9 +88,7 @@ def test_raises_exception_on_unsupported_attestation_type(self) -> None:
rp_id = "localhost"
expected_origin = "http://localhost:5000"

with self.assertRaisesRegex(
Exception, "value is not a valid enumeration member"
):
with self.assertRaises(ValidationError):
MasterKale marked this conversation as resolved.
Show resolved Hide resolved
verify_registration_response(
credential=credential,
expected_challenge=challenge,
Expand Down
2 changes: 1 addition & 1 deletion webauthn/helpers/bytes_to_base64url.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ def bytes_to_base64url(val: bytes) -> str:
"""
Base64URL-encode the provided bytes
"""
return urlsafe_b64encode(val).decode("utf-8").replace("=", "")
return urlsafe_b64encode(val).decode("utf-8").rstrip("=")
120 changes: 91 additions & 29 deletions webauthn/helpers/structs.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
from enum import Enum
from typing import List, Literal, Optional
from typing import Callable, List, Literal, Optional, Any, Dict

from pydantic import BaseModel, validator
from pydantic.fields import ModelField

try:
from pydantic import ( # type: ignore[attr-defined]
BaseModel,
field_validator,
ConfigDict,
FieldValidationInfo,
model_serializer,
)

PYDANTIC_V2 = True
except ImportError:
from pydantic import BaseModel, validator
from pydantic.fields import ModelField # type: ignore[attr-defined]

PYDANTIC_V2 = False

from .base64url_to_bytes import base64url_to_bytes
from .bytes_to_base64url import bytes_to_base64url
from .cose import COSEAlgorithmIdentifier
from .json_loads_base64url_to_bytes import json_loads_base64url_to_bytes
from .snake_case_to_camel_case import snake_case_to_camel_case


def _to_bytes(v: Any) -> Any:
if isinstance(v, bytes):
"""
Return raw bytes from subclasses as well

`strict_bytes_validator()` performs a similar check to this, but it passes through the
subclass as-is and Pydantic then rejects it. Passing the subclass into `bytes()` lets us
return `bytes` and make Pydantic happy.
"""
return bytes(v)
elif isinstance(v, memoryview):
return v.tobytes()
else:
# Allow Pydantic to validate the field as usual to support the full range of bytes-like
# values
return v


class WebAuthnBaseModel(BaseModel):
"""
A subclass of Pydantic's BaseModel that includes convenient defaults
Expand All @@ -24,37 +57,66 @@ class WebAuthnBaseModel(BaseModel):
- Converts camelCase properties to snake_case
"""

class Config:
json_encoders = {bytes: bytes_to_base64url}
json_loads = json_loads_base64url_to_bytes
alias_generator = snake_case_to_camel_case
allow_population_by_field_name = True
if PYDANTIC_V2:
model_config = ConfigDict( # type: ignore[typeddict-unknown-key]
alias_generator=snake_case_to_camel_case,
populate_by_name=True,
ser_json_bytes="base64",
)

@validator("*", pre=True, allow_reuse=True)
def _validate_bytes_fields(cls, v, field: ModelField):
"""
Allow for Pydantic models to define fields as `bytes`, but allow consuming projects to
specify bytes-adjacent values (bytes subclasses, memoryviews, etc...) that otherwise
function like `bytes`. Keeps the library Pythonic.
"""
if field.type_ != bytes:
return v
@field_validator("*", mode="before")
def _pydantic_v2_validate_bytes_fields(
cls, v: Any, info: FieldValidationInfo
) -> Any:
field = cls.model_fields[info.field_name] # type: ignore[attr-defined]

if field.annotation != bytes:
return v

if isinstance(v, bytes):
if isinstance(v, str):
# NOTE:
# Ideally we should only do this when info.mode == "json", but
# that does not work when using the deprecated parse_raw method
return base64url_to_bytes(v)

return _to_bytes(v)

@model_serializer(mode="wrap", when_used="json")
def _pydantic_v2_serialize_bytes_fields(
self, serializer: Callable[..., Dict[str, Any]]
) -> Dict[str, Any]:
"""
Remove trailing "=" from bytes fields serialized as base64 encoded strings.
"""
Return raw bytes from subclasses as well

`strict_bytes_validator()` performs a similar check to this, but it passes through the
subclass as-is and Pydantic then rejects it. Passing the subclass into `bytes()` lets us
return `bytes` and make Pydantic happy.
serialized = serializer(self)

for name, field_info in self.model_fields.items(): # type: ignore[attr-defined]
value = serialized.get(name)
if field_info.annotation is bytes and isinstance(value, str):
serialized[name] = value.rstrip("=")

return serialized

else:

class Config:
json_encoders = {bytes: bytes_to_base64url}
json_loads = json_loads_base64url_to_bytes
alias_generator = snake_case_to_camel_case
allow_population_by_field_name = True

@validator("*", pre=True, allow_reuse=True) # type: ignore[type-var]
def _pydantic_v1_validate_bytes_fields(cls, v: Any, field: ModelField) -> Any:
"""
return bytes(v)
elif isinstance(v, memoryview):
return v.tobytes()
else:
# Allow Pydantic to validate the field as usual to support the full range of bytes-like
# values
return v
Allow for Pydantic models to define fields as `bytes`, but allow consuming projects to
specify bytes-adjacent values (bytes subclasses, memoryviews, etc...) that otherwise
function like `bytes`. Keeps the library Pythonic.
"""
if field.type_ != bytes:
return v

return _to_bytes(v)


################
Expand Down