diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 176d002..afed84b 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -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 @@ -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 diff --git a/requirements.txt b/requirements.txt index 2399d93..b5fdd21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +annotated-types==0.5.0 asn1crypto==1.4.0 black==21.9b0 cbor2==5.4.2.post1 @@ -5,17 +6,18 @@ 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 diff --git a/setup.py b/setup.py index 0bbeed3..98d2120 100644 --- a/setup.py +++ b/setup.py @@ -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', + 'pydantic>=1.10.11', 'pyOpenSSL>=23.2.0', ] ) diff --git a/tests/test_verify_registration_response.py b/tests/test_verify_registration_response.py index bb7dbd6..7dab1df 100644 --- a/tests/test_verify_registration_response.py +++ b/tests/test_verify_registration_response.py @@ -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 @@ -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): verify_registration_response( credential=credential, expected_challenge=challenge, diff --git a/webauthn/helpers/bytes_to_base64url.py b/webauthn/helpers/bytes_to_base64url.py index ab7eeff..1db9767 100644 --- a/webauthn/helpers/bytes_to_base64url.py +++ b/webauthn/helpers/bytes_to_base64url.py @@ -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("=") diff --git a/webauthn/helpers/structs.py b/webauthn/helpers/structs.py index 5aaac61..4bc3cd6 100644 --- a/webauthn/helpers/structs.py +++ b/webauthn/helpers/structs.py @@ -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 @@ -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) ################